From 1b3c6c0cb1a54332173a26052e41c5bdf1792193 Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Thu, 5 Nov 2020 11:17:26 -0500 Subject: [PATCH 1/9] added conversion to epochs --- backend/classification/config/constants.py | 10 ++++++++-- backend/classification/metric/__init__.py | 0 backend/classification/metric/epochs.py | 13 +++++++++++++ backend/classification/model.py | 7 ++++++- backend/classification/postprocessor.py | 4 ++-- 5 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 backend/classification/metric/__init__.py create mode 100644 backend/classification/metric/epochs.py diff --git a/backend/classification/config/constants.py b/backend/classification/config/constants.py index 10414a75..72916754 100644 --- a/backend/classification/config/constants.py +++ b/backend/classification/config/constants.py @@ -8,6 +8,14 @@ class Sex(Enum): M = 2 +class SleepStage(Enum): + W = 0 + N1 = 1 + N2 = 2 + N3 = 3 + REM = 4 + + class HiddenMarkovModelProbability(Enum): emission = auto() start = auto() @@ -38,5 +46,3 @@ def get_filename(self): [85, 125] ] ACCEPTED_AGE_RANGE = [AGE_FEATURE_BINS[0][0], AGE_FEATURE_BINS[-1][-1]] - -N_STAGES = 5 diff --git a/backend/classification/metric/__init__.py b/backend/classification/metric/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/classification/metric/epochs.py b/backend/classification/metric/epochs.py new file mode 100644 index 00000000..d0709282 --- /dev/null +++ b/backend/classification/metric/epochs.py @@ -0,0 +1,13 @@ +import numpy as np + +from classification.config.constants import EPOCH_DURATION, SleepStage + + +def get_labelled_epochs(predictions, start_timestamp): + n_epochs = len(predictions) + + timestamps = np.arange(n_epochs * EPOCH_DURATION, step=EPOCH_DURATION) + start_timestamp + ordered_sleep_stage_names = np.array([SleepStage(stage_index).name for stage_index in range(len(SleepStage))]) + stages = ordered_sleep_stage_names[predictions] + + return {'timestamps': timestamps, 'stages': stages} diff --git a/backend/classification/model.py b/backend/classification/model.py index db1f2a00..948de418 100644 --- a/backend/classification/model.py +++ b/backend/classification/model.py @@ -4,6 +4,7 @@ from classification.validation import validate from classification.postprocessor import get_hmm_model from classification.load_model import load_model, load_hmm +from classification.metric.epochs import get_labelled_epochs class SleepStagesClassifier(): @@ -40,7 +41,11 @@ def predict(self, raw_eeg, info): print(predictions) - return predictions + labelled_epochs = get_labelled_epochs(predictions, info['in_bed_seconds']) + + print(labelled_epochs) + + return labelled_epochs def _get_predictions(self, features): return self.model.run(None, {self.model_input_name: features})[0] diff --git a/backend/classification/postprocessor.py b/backend/classification/postprocessor.py index 260fdae9..bd918e17 100644 --- a/backend/classification/postprocessor.py +++ b/backend/classification/postprocessor.py @@ -2,7 +2,7 @@ from classification.config.constants import ( HiddenMarkovModelProbability, - N_STAGES, + SleepStage, ) @@ -15,7 +15,7 @@ def get_hmm_model(state): describes the according hidden markov model state Returns: an instance of a trained MultinomialHMM """ - hmm_model = MultinomialHMM(n_components=N_STAGES) + hmm_model = MultinomialHMM(n_components=len(SleepStage)) hmm_model.emissionprob_ = state[HiddenMarkovModelProbability.emission.name] hmm_model.startprob_ = state[HiddenMarkovModelProbability.start.name] From e2945d49d2a96bf890d71682e9a11f89cb021769 Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Thu, 5 Nov 2020 13:11:07 -0500 Subject: [PATCH 2/9] created classification request obj --- backend/app.py | 20 ++++++++--------- backend/classification/features/__init__.py | 15 ++++--------- backend/classification/model.py | 18 +++++----------- backend/classification/request.py | 24 +++++++++++++++++++++ backend/classification/validation.py | 8 +++---- 5 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 backend/classification/request.py diff --git a/backend/app.py b/backend/app.py index 11e736d9..e54b4e8a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -7,6 +7,7 @@ from classification.exceptions import ClassificationError from classification.config.constants import Sex, ALLOWED_FILE_EXTENSIONS from classification.model import SleepStagesClassifier +from classification.request import ClassificationRequest app = Flask(__name__) model = SleepStagesClassifier() @@ -48,22 +49,19 @@ def analyze_sleep(): form_data = request.form.to_dict() try: - age = int(form_data['age']) - sex = Sex[form_data['sex']] - stream_start = int(form_data['stream_start']) - bedtime = int(form_data['bedtime']) - wakeup = int(form_data['wakeup']) + classification_request = ClassificationRequest( + age=int(form_data['age']), + sex=Sex[form_data['sex']], + stream_start=int(form_data['stream_start']), + bedtime=int(form_data['bedtime']), + wakeup=int(form_data['wakeup']), + ) except (KeyError, ValueError): return 'Missing or invalid request parameters', HTTPStatus.BAD_REQUEST try: raw_array = get_raw_array(file) - model.predict(raw_array, info={ - 'sex': sex, - 'age': age, - 'in_bed_seconds': bedtime - stream_start, - 'out_of_bed_seconds': wakeup - stream_start - }) + model.predict(raw_array, classification_request) except ClassificationError as e: return e.message, HTTPStatus.BAD_REQUEST diff --git a/backend/classification/features/__init__.py b/backend/classification/features/__init__.py index c21ae168..ee76c1f9 100644 --- a/backend/classification/features/__init__.py +++ b/backend/classification/features/__init__.py @@ -6,24 +6,17 @@ ) -def get_features(signal, info): +def get_features(signal, request): """Returns the raw features Input: - raw_eeg: instance of mne.io.RawArray Should contain 2 channels (1: FPZ-CZ, 2: PZ-OZ) - - info: dict - Should contain the following keys: - - sex: instance of Sex enum - - age: indicates the subject's age - - in_bed_seconds: timespan, in seconds, from which - the subject started the recording and went to bed - - out_of_bed_seconds: timespan, in seconds, from which - the subject started the recording and got out of bed + - info: instance of ClassificationRequest Returns ------- - features X in a vector of (nb_epochs, nb_features) """ - X_eeg = get_eeg_features(signal, info['in_bed_seconds'], info['out_of_bed_seconds']) - X_categorical = get_non_eeg_features(info['age'], info['sex'], X_eeg.shape[0]) + X_eeg = get_eeg_features(signal, request.in_bed_seconds, request.out_of_bed_seconds) + X_categorical = get_non_eeg_features(request.age, request.sex, X_eeg.shape[0]) return np.append(X_categorical, X_eeg, axis=1).astype(np.float32) diff --git a/backend/classification/model.py b/backend/classification/model.py index 948de418..7d9d735c 100644 --- a/backend/classification/model.py +++ b/backend/classification/model.py @@ -1,7 +1,6 @@ """defines models which predict sleep stages based off EEG signals""" from classification.features import get_features -from classification.validation import validate from classification.postprocessor import get_hmm_model from classification.load_model import load_model, load_hmm from classification.metric.epochs import get_labelled_epochs @@ -15,24 +14,17 @@ def __init__(self): self.postprocessor_state = load_hmm() self.postprocessor = get_hmm_model(self.postprocessor_state) - def predict(self, raw_eeg, info): + def predict(self, raw_eeg, request): """ Input: - raw_eeg: instance of mne.io.RawArray Should contain 2 channels (1: FPZ-CZ, 2: PZ-OZ) - - info: dict - Should contain the following keys: - - sex: instance of Sex enum - - age: indicates the subject's age - - in_bed_seconds: timespan, in seconds, from which - the subject started the recording and went to bed - - out_of_bed_seconds: timespan, in seconds, from which - the subject started the recording and got out of bed + - request: instance of ClassificationRequest Returns: array of predicted sleep stages """ - validate(raw_eeg, info) - features = get_features(raw_eeg, info) + request.validate(raw_eeg) + features = get_features(raw_eeg, request) print(features, features.shape) @@ -41,7 +33,7 @@ def predict(self, raw_eeg, info): print(predictions) - labelled_epochs = get_labelled_epochs(predictions, info['in_bed_seconds']) + labelled_epochs = get_labelled_epochs(predictions, request.stream_start) print(labelled_epochs) diff --git a/backend/classification/request.py b/backend/classification/request.py new file mode 100644 index 00000000..cd4238fa --- /dev/null +++ b/backend/classification/request.py @@ -0,0 +1,24 @@ +from classification.validation import validate + + +class ClassificationRequest(): + def __init__(self, sex, age, stream_start, bedtime, wakeup): + self.sex = sex + self.age = age + self.stream_start = stream_start + self.bedtime = bedtime + self.wakeup = wakeup + self.is_valid = None + + @property + def in_bed_seconds(self): + """timespan, in seconds, from which the subject started the recording and went to bed""" + return self.bedtime - self.stream_start + + @property + def out_of_bed_seconds(self): + """timespan, in seconds, from which the subject started the recording and got out of bed""" + return self.wakeup - self.stream_start + + def validate(self, raw_eeg): + validate(raw_eeg, self) diff --git a/backend/classification/validation.py b/backend/classification/validation.py index 587f928e..24dd49ef 100644 --- a/backend/classification/validation.py +++ b/backend/classification/validation.py @@ -9,10 +9,10 @@ ) -def validate(raw_eeg, info): - _validate_timestamps(info['in_bed_seconds'], info['out_of_bed_seconds']) - _validate_file_with_timestamps(raw_eeg, info['out_of_bed_seconds']) - _validate_age(info['age']) +def validate(raw_eeg, request): + _validate_timestamps(request.in_bed_seconds, request.out_of_bed_seconds) + _validate_file_with_timestamps(raw_eeg, request.out_of_bed_seconds) + _validate_age(request.age) def _validate_timestamps(in_bed_seconds, out_of_bed_seconds): From 6cd1c6eee30e9e2b996949d2402e16aa6996a8dd Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Thu, 5 Nov 2020 15:24:06 -0500 Subject: [PATCH 3/9] added response object --- backend/app.py | 10 ++-- backend/classification/metric/__init__.py | 0 backend/classification/metric/epochs.py | 13 ----- backend/classification/model.py | 13 ++--- backend/classification/request.py | 60 +++++++++++++++++++++++ polydodo.code-workspace | 3 +- 6 files changed, 72 insertions(+), 27 deletions(-) delete mode 100644 backend/classification/metric/__init__.py delete mode 100644 backend/classification/metric/epochs.py diff --git a/backend/app.py b/backend/app.py index e54b4e8a..008d2f32 100644 --- a/backend/app.py +++ b/backend/app.py @@ -7,10 +7,10 @@ from classification.exceptions import ClassificationError from classification.config.constants import Sex, ALLOWED_FILE_EXTENSIONS from classification.model import SleepStagesClassifier -from classification.request import ClassificationRequest +from classification.request import ClassificationRequest, ClassificationResponse app = Flask(__name__) -model = SleepStagesClassifier() +sleep_stage_classifier = SleepStagesClassifier() def allowed_file(filename): @@ -61,12 +61,12 @@ def analyze_sleep(): try: raw_array = get_raw_array(file) - model.predict(raw_array, classification_request) + predictions = sleep_stage_classifier.predict(raw_array, classification_request) + classification_response = ClassificationResponse(classification_request, predictions) except ClassificationError as e: return e.message, HTTPStatus.BAD_REQUEST - with open("assets/mock_response.json", "r") as mock_response_file: - return mock_response_file.read() + return classification_response.get_response() CORS(app, diff --git a/backend/classification/metric/__init__.py b/backend/classification/metric/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/classification/metric/epochs.py b/backend/classification/metric/epochs.py deleted file mode 100644 index d0709282..00000000 --- a/backend/classification/metric/epochs.py +++ /dev/null @@ -1,13 +0,0 @@ -import numpy as np - -from classification.config.constants import EPOCH_DURATION, SleepStage - - -def get_labelled_epochs(predictions, start_timestamp): - n_epochs = len(predictions) - - timestamps = np.arange(n_epochs * EPOCH_DURATION, step=EPOCH_DURATION) + start_timestamp - ordered_sleep_stage_names = np.array([SleepStage(stage_index).name for stage_index in range(len(SleepStage))]) - stages = ordered_sleep_stage_names[predictions] - - return {'timestamps': timestamps, 'stages': stages} diff --git a/backend/classification/model.py b/backend/classification/model.py index 7d9d735c..8b4faf37 100644 --- a/backend/classification/model.py +++ b/backend/classification/model.py @@ -3,7 +3,6 @@ from classification.features import get_features from classification.postprocessor import get_hmm_model from classification.load_model import load_model, load_hmm -from classification.metric.epochs import get_labelled_epochs class SleepStagesClassifier(): @@ -22,8 +21,8 @@ def predict(self, raw_eeg, request): - request: instance of ClassificationRequest Returns: array of predicted sleep stages """ + self._validate_request(raw_eeg, request) - request.validate(raw_eeg) features = get_features(raw_eeg, request) print(features, features.shape) @@ -31,13 +30,11 @@ def predict(self, raw_eeg, request): predictions = self._get_predictions(features) predictions = self._get_postprocessed_predictions(predictions) - print(predictions) - - labelled_epochs = get_labelled_epochs(predictions, request.stream_start) + return predictions - print(labelled_epochs) - - return labelled_epochs + def _validate_request(self, raw_eeg, request): + request.validate(raw_eeg) + request.stream_end = raw_eeg.times[-1] def _get_predictions(self, features): return self.model.run(None, {self.model_input_name: features})[0] diff --git a/backend/classification/request.py b/backend/classification/request.py index cd4238fa..abfcb9bc 100644 --- a/backend/classification/request.py +++ b/backend/classification/request.py @@ -1,3 +1,6 @@ +import numpy as np + +from classification.config.constants import EPOCH_DURATION, SleepStage from classification.validation import validate @@ -8,6 +11,8 @@ def __init__(self, sex, age, stream_start, bedtime, wakeup): self.stream_start = stream_start self.bedtime = bedtime self.wakeup = wakeup + + self.stream_end = None self.is_valid = None @property @@ -20,5 +25,60 @@ def out_of_bed_seconds(self): """timespan, in seconds, from which the subject started the recording and got out of bed""" return self.wakeup - self.stream_start + @property + def n_epochs(self): + return (self.wakeup - self.bedtime) / EPOCH_DURATION + def validate(self, raw_eeg): validate(raw_eeg, self) + + +class ClassificationResponse(): + def __init__(self, request, predictions): + self.sex = request.sex + self.age = request.age + self.stream_start = request.stream_start + self.stream_end = request.stream_end + self.bedtime = request.bedtime + self.wakeup = request.wakeup + self.n_epochs = request.n_epochs + + self.predictions = predictions + + @property + def sleep_stages(self): + ordered_sleep_stage_names = np.array([SleepStage(stage_index).name for stage_index in range(len(SleepStage))]) + return ordered_sleep_stage_names[self.predictions] + + @property + def epochs(self): + timestamps = np.arange(self.n_epochs * EPOCH_DURATION, step=EPOCH_DURATION) + self.bedtime + return {'timestamps': timestamps.tolist(), 'stages': self.sleep_stages.tolist()} + + @property + def metadata(self): + return { + "sessionStartTime": self.stream_start, + "sessionEndTime": self.stream_end, + "totalSessionTime": self.stream_end - self.stream_start, + "bedTime": self.bedtime, + "wakeUpTime": None, + "totalBedTime": None, + } + + @property + def subject(self): + return { + 'age': self.age, + 'sex': self.sex.name, + } + + def get_response(self): + return { + 'epochs': self.epochs, + 'report': None, + 'metadata': self.metadata, + 'subject': self.subject, + 'board': None, + 'spectrograms': None, + } diff --git a/polydodo.code-workspace b/polydodo.code-workspace index fe66e646..02033be8 100644 --- a/polydodo.code-workspace +++ b/polydodo.code-workspace @@ -43,7 +43,8 @@ "python.linting.enabled": true, "python.linting.flake8Enabled": true, "python.formatting.provider": "autopep8", - "python.pythonPath": "/usr/bin/python" + "python.pythonPath": "/usr/bin/python", + "git.pullTags": false }, "extensions": { "recommendations": [ From 59f31ebf8db4b2a81557abfe8346f55ec48b5313 Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Thu, 5 Nov 2020 22:27:43 -0500 Subject: [PATCH 4/9] extracted validation outside of model --- backend/app.py | 13 ++++++------- backend/classification/model.py | 5 ----- backend/classification/request.py | 15 ++++++++------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/backend/app.py b/backend/app.py index 008d2f32..4e2a7969 100644 --- a/backend/app.py +++ b/backend/app.py @@ -47,6 +47,7 @@ def analyze_sleep(): return 'File format not allowed', HTTPStatus.BAD_REQUEST form_data = request.form.to_dict() + raw_array = get_raw_array(file) try: classification_request = ClassificationRequest( @@ -55,16 +56,14 @@ def analyze_sleep(): stream_start=int(form_data['stream_start']), bedtime=int(form_data['bedtime']), wakeup=int(form_data['wakeup']), + raw_eeg=raw_array, ) - except (KeyError, ValueError): + classification_request.validate() + except (KeyError, ValueError, ClassificationError): return 'Missing or invalid request parameters', HTTPStatus.BAD_REQUEST - try: - raw_array = get_raw_array(file) - predictions = sleep_stage_classifier.predict(raw_array, classification_request) - classification_response = ClassificationResponse(classification_request, predictions) - except ClassificationError as e: - return e.message, HTTPStatus.BAD_REQUEST + predictions = sleep_stage_classifier.predict(raw_array, classification_request) + classification_response = ClassificationResponse(classification_request, predictions) return classification_response.get_response() diff --git a/backend/classification/model.py b/backend/classification/model.py index 8b4faf37..9bbd37fa 100644 --- a/backend/classification/model.py +++ b/backend/classification/model.py @@ -21,7 +21,6 @@ def predict(self, raw_eeg, request): - request: instance of ClassificationRequest Returns: array of predicted sleep stages """ - self._validate_request(raw_eeg, request) features = get_features(raw_eeg, request) @@ -32,10 +31,6 @@ def predict(self, raw_eeg, request): return predictions - def _validate_request(self, raw_eeg, request): - request.validate(raw_eeg) - request.stream_end = raw_eeg.times[-1] - def _get_predictions(self, features): return self.model.run(None, {self.model_input_name: features})[0] diff --git a/backend/classification/request.py b/backend/classification/request.py index abfcb9bc..c7c1ce7f 100644 --- a/backend/classification/request.py +++ b/backend/classification/request.py @@ -5,14 +5,15 @@ class ClassificationRequest(): - def __init__(self, sex, age, stream_start, bedtime, wakeup): + def __init__(self, sex, age, stream_start, bedtime, wakeup, raw_eeg): self.sex = sex self.age = age self.stream_start = stream_start self.bedtime = bedtime self.wakeup = wakeup - self.stream_end = None + self.stream_duration = raw_eeg.times[-1] + self.raw_eeg = raw_eeg self.is_valid = None @property @@ -29,8 +30,8 @@ def out_of_bed_seconds(self): def n_epochs(self): return (self.wakeup - self.bedtime) / EPOCH_DURATION - def validate(self, raw_eeg): - validate(raw_eeg, self) + def validate(self): + validate(self.raw_eeg, self) class ClassificationResponse(): @@ -38,7 +39,7 @@ def __init__(self, request, predictions): self.sex = request.sex self.age = request.age self.stream_start = request.stream_start - self.stream_end = request.stream_end + self.stream_duration = request.stream_duration self.bedtime = request.bedtime self.wakeup = request.wakeup self.n_epochs = request.n_epochs @@ -59,8 +60,8 @@ def epochs(self): def metadata(self): return { "sessionStartTime": self.stream_start, - "sessionEndTime": self.stream_end, - "totalSessionTime": self.stream_end - self.stream_start, + "sessionEndTime": self.stream_duration + self.stream_start, + "totalSessionTime": self.stream_duration, "bedTime": self.bedtime, "wakeUpTime": None, "totalBedTime": None, From 3281af86a1d5e7c719cd73a3b0ed9ebd8b484549 Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Thu, 5 Nov 2020 22:53:53 -0500 Subject: [PATCH 5/9] extracted preprocessing --- backend/app.py | 7 ++- backend/classification/features/extraction.py | 19 ++++--- .../classification/features/preprocessing.py | 26 +++------ backend/classification/model.py | 6 +-- backend/classification/request.py | 54 +------------------ backend/classification/response.py | 54 +++++++++++++++++++ 6 files changed, 80 insertions(+), 86 deletions(-) create mode 100644 backend/classification/response.py diff --git a/backend/app.py b/backend/app.py index 4e2a7969..06bffaa8 100644 --- a/backend/app.py +++ b/backend/app.py @@ -7,7 +7,9 @@ from classification.exceptions import ClassificationError from classification.config.constants import Sex, ALLOWED_FILE_EXTENSIONS from classification.model import SleepStagesClassifier -from classification.request import ClassificationRequest, ClassificationResponse +from classification.request import ClassificationRequest +from classification.response import ClassificationResponse +from classification.features.preprocessing import preprocess app = Flask(__name__) sleep_stage_classifier = SleepStagesClassifier() @@ -62,7 +64,8 @@ def analyze_sleep(): except (KeyError, ValueError, ClassificationError): return 'Missing or invalid request parameters', HTTPStatus.BAD_REQUEST - predictions = sleep_stage_classifier.predict(raw_array, classification_request) + preprocessed_epochs = preprocess(classification_request) + predictions = sleep_stage_classifier.predict(preprocessed_epochs, classification_request) classification_response = ClassificationResponse(classification_request, predictions) return classification_response.get_response() diff --git a/backend/classification/features/extraction.py b/backend/classification/features/extraction.py index 494a9b00..e53667e9 100644 --- a/backend/classification/features/extraction.py +++ b/backend/classification/features/extraction.py @@ -6,14 +6,13 @@ AGE_FEATURE_BINS, ) from classification.features.pipeline import get_feature_union -from classification.features.preprocessing import preprocess -def get_eeg_features(raw_data, in_bed_seconds, out_of_bed_seconds): +def get_eeg_features(epochs, in_bed_seconds, out_of_bed_seconds): """Returns the continuous feature matrix Input ------- - raw_signal: MNE.Raw object with signals with or without annotations + epochs: mne.Epochs object with signals with or without annotations in_bed_seconds: timespan, in seconds, from which the subject started the recording and went to bed out_of_bed_seconds: timespan, in seconds, from which the subject @@ -23,21 +22,21 @@ def get_eeg_features(raw_data, in_bed_seconds, out_of_bed_seconds): ------- Array of size (nb_epochs, nb_continuous_features) """ - features_file = [] + features = [] feature_union = get_feature_union() for channel in EEG_CHANNELS: - chan_data = preprocess(raw_data, channel, in_bed_seconds, out_of_bed_seconds) + channel_epochs = epochs.copy().pick_channels({channel}) + channel_features = feature_union.transform(channel_epochs) - X_features = feature_union.transform(chan_data) - features_file.append(X_features) + features.append(channel_features) print( - f"Done extracting {X_features.shape[1]} features " - f"on {X_features.shape[0]} epochs for {channel}\n" + f"Done extracting {channel_features.shape[1]} features " + f"on {channel_features.shape[0]} epochs for {channel}\n" ) - return np.hstack(tuple(features_file)) + return np.hstack(tuple(features)) def get_non_eeg_features(age, sex, nb_epochs): diff --git a/backend/classification/features/preprocessing.py b/backend/classification/features/preprocessing.py index 0561baa7..eb1b82cd 100644 --- a/backend/classification/features/preprocessing.py +++ b/backend/classification/features/preprocessing.py @@ -12,21 +12,19 @@ ) -def preprocess(raw_data, channel, bed_seconds, out_of_bed_seconds): +def preprocess(classification_request): """Returns preprocessed epochs of the specified channel Input ------- - raw_data: instance of mne.Raw - channel: channel to preprocess - bed_seconds: number of seconds between start of recording & moment at - which the subjet went to bed (in seconds) - out_of_bed_seconds: number of seconds between start of recording & moment - at which the subjet got out of bed (in seconds) + classification_request: instance of ClassificationRequest """ - raw_data = raw_data.copy() + raw_data = classification_request.raw_eeg.copy() - raw_data = _drop_other_channels(raw_data, channel) - raw_data = _crop_raw_data(raw_data, bed_seconds, out_of_bed_seconds) + raw_data = _crop_raw_data( + raw_data, + classification_request.in_bed_seconds, + classification_request.out_of_bed_seconds, + ) raw_data = _apply_high_pass_filter(raw_data) raw_data = raw_data.resample(DATASET_SAMPLE_RATE) raw_data = _convert_to_epochs(raw_data) @@ -34,14 +32,6 @@ def preprocess(raw_data, channel, bed_seconds, out_of_bed_seconds): return raw_data -def _drop_other_channels(raw_data, channel_to_keep): - """returns mne.Raw with only the channel to keep""" - raw_data.drop_channels( - [ch for ch in raw_data.info['ch_names'] if ch != channel_to_keep]) - - return raw_data - - def _crop_raw_data( raw_data, bed_seconds, diff --git a/backend/classification/model.py b/backend/classification/model.py index 9bbd37fa..eb889153 100644 --- a/backend/classification/model.py +++ b/backend/classification/model.py @@ -13,16 +13,16 @@ def __init__(self): self.postprocessor_state = load_hmm() self.postprocessor = get_hmm_model(self.postprocessor_state) - def predict(self, raw_eeg, request): + def predict(self, epochs, request): """ Input: - - raw_eeg: instance of mne.io.RawArray + - raw_eeg: instance of mne.Epochs Should contain 2 channels (1: FPZ-CZ, 2: PZ-OZ) - request: instance of ClassificationRequest Returns: array of predicted sleep stages """ - features = get_features(raw_eeg, request) + features = get_features(epochs, request) print(features, features.shape) diff --git a/backend/classification/request.py b/backend/classification/request.py index c7c1ce7f..61aac4f2 100644 --- a/backend/classification/request.py +++ b/backend/classification/request.py @@ -1,6 +1,5 @@ -import numpy as np -from classification.config.constants import EPOCH_DURATION, SleepStage +from classification.config.constants import EPOCH_DURATION from classification.validation import validate @@ -32,54 +31,3 @@ def n_epochs(self): def validate(self): validate(self.raw_eeg, self) - - -class ClassificationResponse(): - def __init__(self, request, predictions): - self.sex = request.sex - self.age = request.age - self.stream_start = request.stream_start - self.stream_duration = request.stream_duration - self.bedtime = request.bedtime - self.wakeup = request.wakeup - self.n_epochs = request.n_epochs - - self.predictions = predictions - - @property - def sleep_stages(self): - ordered_sleep_stage_names = np.array([SleepStage(stage_index).name for stage_index in range(len(SleepStage))]) - return ordered_sleep_stage_names[self.predictions] - - @property - def epochs(self): - timestamps = np.arange(self.n_epochs * EPOCH_DURATION, step=EPOCH_DURATION) + self.bedtime - return {'timestamps': timestamps.tolist(), 'stages': self.sleep_stages.tolist()} - - @property - def metadata(self): - return { - "sessionStartTime": self.stream_start, - "sessionEndTime": self.stream_duration + self.stream_start, - "totalSessionTime": self.stream_duration, - "bedTime": self.bedtime, - "wakeUpTime": None, - "totalBedTime": None, - } - - @property - def subject(self): - return { - 'age': self.age, - 'sex': self.sex.name, - } - - def get_response(self): - return { - 'epochs': self.epochs, - 'report': None, - 'metadata': self.metadata, - 'subject': self.subject, - 'board': None, - 'spectrograms': None, - } diff --git a/backend/classification/response.py b/backend/classification/response.py new file mode 100644 index 00000000..87452d68 --- /dev/null +++ b/backend/classification/response.py @@ -0,0 +1,54 @@ +import numpy as np + +from classification.config.constants import EPOCH_DURATION, SleepStage + + +class ClassificationResponse(): + def __init__(self, request, predictions): + self.sex = request.sex + self.age = request.age + self.stream_start = request.stream_start + self.stream_duration = request.stream_duration + self.bedtime = request.bedtime + self.wakeup = request.wakeup + self.n_epochs = request.n_epochs + + self.predictions = predictions + + @property + def sleep_stages(self): + ordered_sleep_stage_names = np.array([SleepStage(stage_index).name for stage_index in range(len(SleepStage))]) + return ordered_sleep_stage_names[self.predictions] + + @property + def epochs(self): + timestamps = np.arange(self.n_epochs * EPOCH_DURATION, step=EPOCH_DURATION) + self.bedtime + return {'timestamps': timestamps.tolist(), 'stages': self.sleep_stages.tolist()} + + @property + def metadata(self): + return { + "sessionStartTime": self.stream_start, + "sessionEndTime": self.stream_duration + self.stream_start, + "totalSessionTime": self.stream_duration, + "bedTime": self.bedtime, + "wakeUpTime": None, + "totalBedTime": None, + } + + @property + def subject(self): + return { + 'age': self.age, + 'sex': self.sex.name, + } + + def get_response(self): + return { + 'epochs': self.epochs, + 'report': None, + 'metadata': self.metadata, + 'subject': self.subject, + 'board': None, + 'spectrograms': None, + } From 3232c8c1f137569f37736c6f1b651215ae822d70 Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Thu, 5 Nov 2020 23:19:30 -0500 Subject: [PATCH 6/9] added spectrogram to response --- backend/app.py | 6 +++- backend/classification/config/constants.py | 4 +-- backend/classification/response.py | 5 +-- .../classification/spectrogram_generator.py | 34 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 backend/classification/spectrogram_generator.py diff --git a/backend/app.py b/backend/app.py index 06bffaa8..734642a1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10,6 +10,7 @@ from classification.request import ClassificationRequest from classification.response import ClassificationResponse from classification.features.preprocessing import preprocess +from classification.spectrogram_generator import SpectrogramGenerator app = Flask(__name__) sleep_stage_classifier = SleepStagesClassifier() @@ -66,7 +67,10 @@ def analyze_sleep(): preprocessed_epochs = preprocess(classification_request) predictions = sleep_stage_classifier.predict(preprocessed_epochs, classification_request) - classification_response = ClassificationResponse(classification_request, predictions) + spectrogram_generator = SpectrogramGenerator(preprocessed_epochs) + classification_response = ClassificationResponse( + classification_request, predictions, spectrogram_generator.generate() + ) return classification_response.get_response() diff --git a/backend/classification/config/constants.py b/backend/classification/config/constants.py index 72916754..cc0bb101 100644 --- a/backend/classification/config/constants.py +++ b/backend/classification/config/constants.py @@ -28,8 +28,8 @@ def get_filename(self): ALLOWED_FILE_EXTENSIONS = ('.txt', '.csv') EEG_CHANNELS = [ - 'EEG Fpz-Cz', - 'EEG Pz-Oz' + 'Fpz-Cz', + 'Pz-Oz' ] EPOCH_DURATION = 30 diff --git a/backend/classification/response.py b/backend/classification/response.py index 87452d68..bc016e85 100644 --- a/backend/classification/response.py +++ b/backend/classification/response.py @@ -4,7 +4,7 @@ class ClassificationResponse(): - def __init__(self, request, predictions): + def __init__(self, request, predictions, spectrogram): self.sex = request.sex self.age = request.age self.stream_start = request.stream_start @@ -13,6 +13,7 @@ def __init__(self, request, predictions): self.wakeup = request.wakeup self.n_epochs = request.n_epochs + self.spectrogram = spectrogram self.predictions = predictions @property @@ -50,5 +51,5 @@ def get_response(self): 'metadata': self.metadata, 'subject': self.subject, 'board': None, - 'spectrograms': None, + 'spectrograms': self.spectrogram, } diff --git a/backend/classification/spectrogram_generator.py b/backend/classification/spectrogram_generator.py new file mode 100644 index 00000000..d4f66296 --- /dev/null +++ b/backend/classification/spectrogram_generator.py @@ -0,0 +1,34 @@ +from itertools import chain + +from mne.time_frequency import psd_welch +import numpy as np + +from classification.features.constants import FREQ_BANDS_RANGE +from classification.config.constants import EEG_CHANNELS + + +class SpectrogramGenerator(): + def __init__(self, epochs): + self.epochs = epochs + + range_frequencies = set(chain(*FREQ_BANDS_RANGE.values())) + self.spectrogram_min_freq = min(range_frequencies) + self.spectrogram_max_freq = max(range_frequencies) + + def generate(self): + psds, freqs = psd_welch( + self.epochs, + fmin=self.spectrogram_min_freq, + fmax=self.spectrogram_max_freq, + ) + psds_db = self._convert_amplitudes_to_decibel(psds) + + spectrogram = {'frequencies': freqs.tolist()} + + for index, eeg_channel in enumerate(EEG_CHANNELS): + spectrogram[eeg_channel.lower()] = psds_db[:, index, :].tolist() + + return spectrogram + + def _convert_amplitudes_to_decibel(self, amplitudes): + return 10 * np.log10(np.maximum(amplitudes, np.finfo(float).tiny)) From 7fe5a004c3c172e12d097c15fbe92ca768356a3e Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Thu, 5 Nov 2020 23:37:23 -0500 Subject: [PATCH 7/9] moved validation to request class --- backend/classification/request.py | 43 +++++++++++++++++++++++-- backend/classification/validation.py | 47 ---------------------------- 2 files changed, 41 insertions(+), 49 deletions(-) delete mode 100644 backend/classification/validation.py diff --git a/backend/classification/request.py b/backend/classification/request.py index 61aac4f2..72eef91e 100644 --- a/backend/classification/request.py +++ b/backend/classification/request.py @@ -1,6 +1,14 @@ from classification.config.constants import EPOCH_DURATION -from classification.validation import validate +from classification.config.constants import ( + FILE_MINIMUM_DURATION, + ACCEPTED_AGE_RANGE, +) +from classification.exceptions import ( + TimestampsError, + FileSizeError, + ClassificationError, +) class ClassificationRequest(): @@ -30,4 +38,35 @@ def n_epochs(self): return (self.wakeup - self.bedtime) / EPOCH_DURATION def validate(self): - validate(self.raw_eeg, self) + self._validate_timestamps() + self._validate_file_with_timestamps() + self._validate_age() + + def _validate_timestamps(self): + has_positive_timespan = self.bedtime > self.stream_start and self.wakeup > self.stream_start + has_got_out_of_bed_after_in_bed = self.wakeup > self.bedtime + has_respected_minimum_bed_time = (self.wakeup - self.bedtime) > FILE_MINIMUM_DURATION + + if not( + has_positive_timespan + and has_got_out_of_bed_after_in_bed + and has_respected_minimum_bed_time + ): + raise TimestampsError() + + def _validate_file_with_timestamps(self): + has_raw_respected_minimum_file_size = self.raw_eeg.times[-1] > FILE_MINIMUM_DURATION + + if not has_raw_respected_minimum_file_size: + raise FileSizeError() + + is_raw_at_least_as_long_as_out_of_bed = self.raw_eeg.times[-1] >= self.out_of_bed_seconds + + if not is_raw_at_least_as_long_as_out_of_bed: + raise TimestampsError() + + def _validate_age(self): + is_in_accepted_range = ACCEPTED_AGE_RANGE[0] <= int(self.age) <= ACCEPTED_AGE_RANGE[1] + + if not(is_in_accepted_range): + raise ClassificationError('invalid age') diff --git a/backend/classification/validation.py b/backend/classification/validation.py deleted file mode 100644 index 24dd49ef..00000000 --- a/backend/classification/validation.py +++ /dev/null @@ -1,47 +0,0 @@ -from classification.config.constants import ( - FILE_MINIMUM_DURATION, - ACCEPTED_AGE_RANGE, -) -from classification.exceptions import ( - TimestampsError, - FileSizeError, - ClassificationError, -) - - -def validate(raw_eeg, request): - _validate_timestamps(request.in_bed_seconds, request.out_of_bed_seconds) - _validate_file_with_timestamps(raw_eeg, request.out_of_bed_seconds) - _validate_age(request.age) - - -def _validate_timestamps(in_bed_seconds, out_of_bed_seconds): - has_positive_timespan = in_bed_seconds > 0 and out_of_bed_seconds > 0 - has_got_out_of_bed_after_in_bed = out_of_bed_seconds > in_bed_seconds - has_respected_minimum_bed_time = (out_of_bed_seconds - in_bed_seconds) > FILE_MINIMUM_DURATION - - if not( - has_positive_timespan - and has_got_out_of_bed_after_in_bed - and has_respected_minimum_bed_time - ): - raise TimestampsError() - - -def _validate_file_with_timestamps(raw_eeg, out_of_bed_seconds): - has_raw_respected_minimum_file_size = raw_eeg.times[-1] > FILE_MINIMUM_DURATION - - if not has_raw_respected_minimum_file_size: - raise FileSizeError() - - is_raw_at_least_as_long_as_out_of_bed = raw_eeg.times[-1] >= out_of_bed_seconds - - if not is_raw_at_least_as_long_as_out_of_bed: - raise TimestampsError() - - -def _validate_age(age): - is_in_accepted_range = ACCEPTED_AGE_RANGE[0] <= int(age) <= ACCEPTED_AGE_RANGE[1] - - if not(is_in_accepted_range): - raise ClassificationError('invalid age') From 09f6ca4d934e7540ec1eaf7e943d9b22d5158e8e Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Fri, 6 Nov 2020 22:44:33 -0500 Subject: [PATCH 8/9] prealocate array && removed stream when converting from hexa to decimal --- backend/classification/file_loading.py | 32 ++++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/backend/classification/file_loading.py b/backend/classification/file_loading.py index c9307180..030de041 100644 --- a/backend/classification/file_loading.py +++ b/backend/classification/file_loading.py @@ -14,11 +14,11 @@ The Cyton board logging format is also described here: [https://docs.openbci.com/docs/02Cyton/CytonSDCard#data-logging-format] """ -from io import StringIO from mne import create_info from mne.io import RawArray import numpy as np +from classification.exceptions import ClassificationError from classification.config.constants import ( EEG_CHANNELS, OPENBCI_CYTON_SAMPLE_RATE, @@ -31,6 +31,7 @@ FILE_COLUMN_OFFSET = 1 CYTON_TOTAL_NB_CHANNELS = 8 +SKIP_ROWS = 2 def get_raw_array(file): @@ -40,31 +41,32 @@ def get_raw_array(file): Returns: - mne.RawArray of the two EEG channels of interest """ - file_content = StringIO(file.stream.read().decode("UTF8")) + lines = file.readlines() + eeg_raw = np.zeros((len(lines) - SKIP_ROWS, len(EEG_CHANNELS))) - eeg_raw = [] - for line in file_content.readlines(): - line_splitted = line.split(',') + for index, line in enumerate(lines[SKIP_ROWS:]): + line_splitted = line.decode('utf-8').split(',') - if len(line_splitted) >= CYTON_TOTAL_NB_CHANNELS: - eeg_raw.append(_get_decimals_from_hexadecimal_strings(line_splitted)) + if len(line_splitted) < CYTON_TOTAL_NB_CHANNELS: + raise ClassificationError() - eeg_raw = SCALE_V_PER_COUNT * np.array(eeg_raw, dtype='object') + eeg_raw[index] = _get_decimals_from_hexadecimal_strings(line_splitted) raw_object = RawArray( - np.transpose(eeg_raw), + SCALE_V_PER_COUNT * np.transpose(eeg_raw), info=create_info( ch_names=EEG_CHANNELS, sfreq=OPENBCI_CYTON_SAMPLE_RATE, ch_types='eeg'), verbose=False, ) - - print('First sample values: ', raw_object[:, 0]) - print('Second sample values: ', raw_object[:, 1]) - print('Number of samples: ', raw_object.n_times) - print('Duration of signal (h): ', raw_object.n_times / (3600 * OPENBCI_CYTON_SAMPLE_RATE)) - print('Channel names: ', raw_object.ch_names) + print(f""" + First sample values: {raw_object[:, 0]} + Second sample values: {raw_object[:, 1]} + Number of samples: {raw_object.n_times} + Duration of signal (h): {raw_object.n_times / (3600 * OPENBCI_CYTON_SAMPLE_RATE)} + Channel names: {raw_object.ch_names} + """) return raw_object From bbc9d67a51b07ed105ff067a7aa20a4e3497a5df Mon Sep 17 00:00:00 2001 From: Claudia Onorato Date: Fri, 6 Nov 2020 22:51:10 -0500 Subject: [PATCH 9/9] code review --- backend/app.py | 3 +-- backend/classification/request.py | 5 +++-- backend/classification/response.py | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/app.py b/backend/app.py index 734642a1..226311b3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -61,7 +61,6 @@ def analyze_sleep(): wakeup=int(form_data['wakeup']), raw_eeg=raw_array, ) - classification_request.validate() except (KeyError, ValueError, ClassificationError): return 'Missing or invalid request parameters', HTTPStatus.BAD_REQUEST @@ -72,7 +71,7 @@ def analyze_sleep(): classification_request, predictions, spectrogram_generator.generate() ) - return classification_response.get_response() + return classification_response.response CORS(app, diff --git a/backend/classification/request.py b/backend/classification/request.py index 72eef91e..38b1e2ee 100644 --- a/backend/classification/request.py +++ b/backend/classification/request.py @@ -21,7 +21,8 @@ def __init__(self, sex, age, stream_start, bedtime, wakeup, raw_eeg): self.stream_duration = raw_eeg.times[-1] self.raw_eeg = raw_eeg - self.is_valid = None + + self._validate() @property def in_bed_seconds(self): @@ -37,7 +38,7 @@ def out_of_bed_seconds(self): def n_epochs(self): return (self.wakeup - self.bedtime) / EPOCH_DURATION - def validate(self): + def _validate(self): self._validate_timestamps() self._validate_file_with_timestamps() self._validate_age() diff --git a/backend/classification/response.py b/backend/classification/response.py index bc016e85..dce5a2b3 100644 --- a/backend/classification/response.py +++ b/backend/classification/response.py @@ -44,7 +44,8 @@ def subject(self): 'sex': self.sex.name, } - def get_response(self): + @property + def response(self): return { 'epochs': self.epochs, 'report': None,