diff --git a/src/femr/labelers/core.py b/src/femr/labelers/core.py index 6baa09eb..c5c4c4c8 100644 --- a/src/femr/labelers/core.py +++ b/src/femr/labelers/core.py @@ -8,13 +8,42 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import Any, Callable, List, Optional, Tuple import datasets import meds import femr.hf_utils +from femr.labelers.omop import identity +import femr.ontology +########################################################## +########################################################## +# +# Helper functions +# +########################################################## +########################################################## + +def identity(x: Any) -> Any: + return x + + +def get_death_concepts() -> List[str]: + return [ + meds.death_code, + ] + +def move_datetime_to_end_of_day(date: datetime.datetime) -> datetime.datetime: + return date.replace(hour=23, minute=59, second=0) + +########################################################## +########################################################## +# +# Shared classes +# +########################################################## +########################################################## @dataclass(frozen=True) class TimeHorizon: @@ -88,6 +117,9 @@ def apply( num_proc=num_proc, ) + def get_patient_start_end_times(patient): + """Return the datetimes that we consider the (start, end) of this patient.""" + return (patient["events"][0]["time"], patient["events"][-1]["time"]) ########################################################## # Specific Labeler Superclasses @@ -287,6 +319,91 @@ def label(self, patient: meds.Patient) -> List[meds.Label]: return n_labels + +class CodeLabeler(TimeHorizonEventLabeler): + """Apply a label based on 1+ outcome_codes' occurrence(s) over a fixed time horizon.""" + + def __init__( + self, + outcome_codes: List[str], + time_horizon: TimeHorizon, + prediction_codes: Optional[List[str]] = None, + prediction_time_adjustment_func: Optional[Callable] = None, + ): + """Create a CodeLabeler, which labels events whose index in your Ontology is in `self.outcome_codes` + + Args: + prediction_codes (List[int]): Events that count as an occurrence of the outcome. + time_horizon (TimeHorizon): An interval of time. If the event occurs during this time horizon, then + the label is TRUE. Otherwise, FALSE. + prediction_codes (Optional[List[int]]): If not None, limit events at which you make predictions to + only events with an `event.code` in these codes. + prediction_time_adjustment_func (Optional[Callable]). A function that takes in a `datetime.datetime` + and returns a different `datetime.datetime`. Defaults to the identity function. + """ + self.outcome_codes: List[str] = outcome_codes + self.time_horizon: TimeHorizon = time_horizon + self.prediction_codes: Optional[List[str]] = prediction_codes + self.prediction_time_adjustment_func: Callable = ( + prediction_time_adjustment_func if prediction_time_adjustment_func is not None else identity # type: ignore + ) + + def get_prediction_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return each event's start time (possibly modified by prediction_time_adjustment_func) + as the time to make a prediction. Default to all events whose `code` is in `self.prediction_codes`.""" + times: List[datetime.datetime] = [] + last_time = None + for e in patient["events"]: + prediction_time: datetime.datetime = self.prediction_time_adjustment_func(e.start) + if ((self.prediction_codes is None) or (e.code in self.prediction_codes)) and ( + last_time != prediction_time + ): + times.append(prediction_time) + last_time = prediction_time + return times + + def get_time_horizon(self) -> TimeHorizon: + return self.time_horizon + + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return the start times of this patient's events whose `code` is in `self.outcome_codes`.""" + times: List[datetime.datetime] = [] + for event in patient.events: + if event.code in self.outcome_codes: + times.append(event.start) + return times + + def allow_same_time_labels(self) -> bool: + # We cannot allow labels at the same time as the codes since they will generally be available as features ... + return False + + +class OMOPConceptCodeLabeler(CodeLabeler): + """Same as CodeLabeler, but add the extra step of mapping OMOP concept IDs + (stored in `omop_concept_ids`) to femr codes (stored in `codes`).""" + + # parent OMOP concept codes, from which all the outcome + # are derived (as children from our ontology) + original_omop_concept_codes: List[str] = [] + + def __init__( + self, + ontology: femr.ontology.Ontology, + time_horizon: TimeHorizon, + prediction_codes: Optional[List[str]] = None, + prediction_time_adjustment_func: Optional[Callable] = None, + ): + outcome_codes: List[str] = ontology.get_all_children(self.original_omop_concept_codes) + super().__init__( + outcome_codes=outcome_codes, + time_horizon=time_horizon, + prediction_codes=prediction_codes, + prediction_time_adjustment_func=prediction_time_adjustment_func + if prediction_time_adjustment_func + else identity, + ) + + def compute_random_num(seed: int, num_1: int, num_2: int, modulus: int = 100): network_num_1 = struct.pack("!q", num_1) network_num_2 = struct.pack("!q", num_2) @@ -303,3 +420,4 @@ def compute_random_num(seed: int, num_1: int, num_2: int, modulus: int = 100): result = (result * 256 + hash_value[i]) % modulus return result + diff --git a/src/femr/labelers/ehrshot.py b/src/femr/labelers/ehrshot.py new file mode 100644 index 00000000..69e28493 --- /dev/null +++ b/src/femr/labelers/ehrshot.py @@ -0,0 +1,499 @@ +"""EHRSHOT tasks from Wornow et al. 2023.""" +from __future__ import annotations + +import datetime +from typing import Any, Callable, List, Optional + +import meds +import pandas as pd + +from femr.labelers.omop import get_inpatient_admission_discharge_times, get_outpatient_visit_measurements +from femr.labelers.omop_labs import InstantLabValueLabeler +import femr.ontology + +from .core import ( + TimeHorizon, + TimeHorizonEventLabeler, + Labeler, + move_datetime_to_end_of_day +) + + +########################################################## +########################################################## +# "Operational Outcomes" Tasks +# +# See: https://www.medrxiv.org/content/10.1101/2022.04.15.22273900v1 +# details on how this was reproduced. +# Citation: Guo et al. +# "EHR foundation models improve robustness in the presence of temporal distribution shift" +# Scientific Reports. 2023. +########################################################## +########################################################## + +class Guo_LongLOSLabeler(Labeler): + """Long LOS prediction task from Guo et al. 2023. + + Binary prediction task @ 11:59PM on the day of admission whether the patient stays in hospital for >=7 days. + + Excludes: + - Visits where discharge occurs on the same day as admission + """ + + def __init__( + self, + ontology: femr.ontology.Ontology, + ): + self.ontology: femr.ontology.Ontology = ontology + self.long_time: datetime.timedelta = datetime.timedelta(days=7) + self.prediction_time_adjustment_func = move_datetime_to_end_of_day + + def label(self, patient: meds.Patient) -> List[meds.Label]: + """Label all admissions with admission length >= `self.long_time`""" + labels: List[meds.Label] = [] + for admission_time, discharge_time in get_inpatient_admission_discharge_times(patient, self.ontology): + # If admission and discharge are on the same day, then ignore + if admission_time.date() == discharge_time.date(): + continue + is_long_admission: bool = (discharge_time - admission_time) >= self.long_time + prediction_time: datetime.datetime = self.prediction_time_adjustment_func(admission_time) + labels.append(meds.Label(patient_id=patient["patient_id"], prediction_time=prediction_time, boolean_value=is_long_admission)) + return labels + +class Guo_30DayReadmissionLabeler(TimeHorizonEventLabeler): + """30-day readmissions prediction task from Guo et al. 2023. + + Binary prediction task @ 11:59PM on the day of disharge whether the patient will be readmitted within 30 days. + + Excludes: + - Patients readmitted on same day as discharge + """ + + def __init__( + self, + ontology: femr.ontology.Ontology, + ): + self.ontology: femr.ontology.Ontology = ontology + self.time_horizon: TimeHorizon = TimeHorizon( + start=datetime.timedelta(minutes=1), end=datetime.timedelta(days=30) + ) + self.prediction_time_adjustment_func = move_datetime_to_end_of_day + + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return the start times of inpatient admissions.""" + times: List[datetime.datetime] = [] + for admission_time, __ in get_inpatient_admission_discharge_times(patient, self.ontology): + times.append(admission_time) + return times + + def get_prediction_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return end of admission as prediction timm.""" + times: List[datetime.datetime] = [] + admission_times = set() + for admission_time, discharge_time in get_inpatient_admission_discharge_times(patient, self.ontology): + prediction_time: datetime.datetime = self.prediction_time_adjustment_func(discharge_time) + # Ignore patients who are readmitted the same day they were discharged b/c of data leakage + if prediction_time.replace(hour=0, minute=0, second=0, microsecond=0) in admission_times: + continue + times.append(prediction_time) + admission_times.add(admission_time.replace(hour=0, minute=0, second=0, microsecond=0)) + times = sorted(list(set(times))) + return times + + def get_time_horizon(self) -> TimeHorizon: + return self.time_horizon + + +class Guo_ICUAdmissionLabeler(WithinVisitLabeler): + """ICU admission prediction task from Guo et al. 2023. + + Binary prediction task @ 11:59PM on the day of admission + whether the patient will be admitted to the ICU during their admission. + + Excludes: + - Patients transfered on same day as admission + - Visits where discharge occurs on the same day as admission + """ + + def __init__( + self, + ontology: femr.ontology.Ontology, + ): + super().__init__( + ontology=ontology, + visit_start_adjust_func=move_datetime_to_end_of_day, + visit_end_adjust_func=None, + ) + + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + # Return the start times of all ICU admissions -- this is our outcome + return [e.start for e in get_icu_events(patient, self.ontology)] # type: ignore + + def get_visit_measurements(self, patient: meds.Patient) -> List[meds.Measurement]: + """Return all inpatient visits where ICU transfer does not occur on the same day as admission.""" + # Get all inpatient visits -- each visit comprises a prediction (start, end) time horizon + all_visits: List[meds.Measurement] = get_outpatient_visit_measurements(patient, self.ontology) + # Exclude visits where ICU admission occurs on the same day as admission + icu_transfer_dates: List[datetime.datetime] = [ + x.replace(hour=0, minute=0, second=0, microsecond=0) for x in self.get_outcome_times(patient) + ] + valid_visits: List[meds.Measurement] = [] + for start, visit in all_visits: + # If admission and discharge are on the same day, then ignore + if start.date() == visit['metadata']['end'].date(): + continue + # If ICU transfer occurs on the same day as admission, then ignore + if start.replace(hour=0, minute=0, second=0, microsecond=0) in icu_transfer_dates: + continue + valid_visits.append(visit) + return valid_visits + + +########################################################## +########################################################## +# "Abnormal Lab Value" Tasks +# +# See: https://arxiv.org/abs/2307.02028 +# Citation: Wornow et al. +# EHRSHOT: An EHR Benchmark for Few-Shot Evaluation of Foundation Models +# NeurIPS (2023). +########################################################## +########################################################## + +class ThrombocytopeniaInstantLabValueLabeler(InstantLabValueLabeler): + """lab-based definition for thrombocytopenia based on platelet count (10^9/L). + Thresholds: mild (<150), moderate(<100), severe(<50), and reference range.""" + + original_omop_concept_codes = [ + "LOINC/LP393218-5", + "LOINC/LG32892-8", + "LOINC/777-3", + ] + + def value_to_label(self, raw_value: str, unit: Optional[str]) -> str: + if raw_value.lower() in ["normal", "adequate"]: + return "normal" + value = float(raw_value) + if value < 50: + return "severe" + elif value < 100: + return "moderate" + elif value < 150: + return "mild" + return "normal" + + +class HyperkalemiaInstantLabValueLabeler(InstantLabValueLabeler): + """lab-based definition for hyperkalemia using blood potassium concentration (mmol/L). + Thresholds: mild(>5.5),moderate(>6),severe(>7), and abnormal range.""" + + original_omop_concept_codes = [ + "LOINC/LG7931-1", + "LOINC/LP386618-5", + "LOINC/LG10990-6", + "LOINC/6298-4", + "LOINC/2823-3", + ] + + def value_to_label(self, raw_value: str, unit: Optional[str]) -> str: + if raw_value.lower() in ["normal", "adequate"]: + return "normal" + value = float(raw_value) + if unit is not None: + unit = unit.lower() + if unit.startswith("mmol/l"): + # mmol/L + # Original OMOP concept ID: 8753 + value = value + elif unit.startswith("meq/l"): + # mEq/L (1-to-1 -> mmol/L) + # Original OMOP concept ID: 9557 + value = value + elif unit.startswith("mg/dl"): + # mg / dL (divide by 18 to get mmol/L) + # Original OMOP concept ID: 8840 + value = value / 18.0 + else: + raise ValueError(f"Unknown unit: {unit}") + else: + raise ValueError(f"Unknown unit: {unit}") + if value > 7: + return "severe" + elif value > 6.0: + return "moderate" + elif value > 5.5: + return "mild" + return "normal" + + +class HypoglycemiaInstantLabValueLabeler(InstantLabValueLabeler): + """lab-based definition for hypoglycemia using blood glucose concentration (mmol/L). + Thresholds: mild(<3), moderate(<3.5), severe(<=3.9), and abnormal range.""" + + original_omop_concept_codes = [ + "SNOMED/33747003", + "LOINC/LP416145-3", + "LOINC/14749-6", + ] + + def value_to_label(self, raw_value: str, unit: Optional[str]) -> str: + if raw_value.lower() in ["normal", "adequate"]: + return "normal" + value = float(raw_value) + if unit is not None: + unit = unit.lower() + if unit.startswith("mg/dl"): + # mg / dL + # Original OMOP concept ID: 8840, 9028 + value = value / 18 + elif unit.startswith("mmol/l"): + # mmol / L (x 18 to get mg/dl) + # Original OMOP concept ID: 8753 + value = value + else: + raise ValueError(f"Unknown unit: {unit}") + else: + raise ValueError(f"Unknown unit: {unit}") + if value < 3: + return "severe" + elif value < 3.5: + return "moderate" + elif value <= 3.9: + return "mild" + return "normal" + + +class HyponatremiaInstantLabValueLabeler(InstantLabValueLabeler): + """lab-based definition for hyponatremia based on blood sodium concentration (mmol/L). + Thresholds: mild (<=135),moderate(<130),severe(<125), and abnormal range.""" + + original_omop_concept_codes = ["LOINC/LG11363-5", "LOINC/2951-2", "LOINC/2947-0"] + + def value_to_label(self, raw_value: str, unit: Optional[str]) -> str: + if raw_value.lower() in ["normal", "adequate"]: + return "normal" + value = float(raw_value) + if value < 125: + return "severe" + elif value < 130: + return "moderate" + elif value <= 135: + return "mild" + return "normal" + + +class AnemiaInstantLabValueLabeler(InstantLabValueLabeler): + """lab-based definition for anemia based on hemoglobin levels (g/L). + Thresholds: mild(<120),moderate(<110),severe(<70), and reference range""" + + original_omop_concept_codes = [ + "LOINC/LP392452-1", + ] + + def value_to_label(self, raw_value: str, unit: Optional[str]) -> str: + if raw_value.lower() in ["normal", "adequate"]: + return "normal" + value = float(raw_value) + if unit is not None: + unit = unit.lower() + if unit.startswith("g/dl"): + # g / dL + # Original OMOP concept ID: 8713 + # NOTE: This weird *10 / 100 is how Lawrence did it + value = value * 10 + elif unit.startswith("mg/dl"): + # mg / dL (divide by 1000 to get g/dL) + # Original OMOP concept ID: 8840 + # NOTE: This weird *10 / 100 is how Lawrence did it + value = value / 100 + elif unit.startswith("g/l"): + value = value + else: + raise ValueError(f"Unknown unit: {unit}") + else: + raise ValueError(f"Unknown unit: {unit}") + if value < 70: + return "severe" + elif value < 110: + return "moderate" + elif value < 120: + return "mild" + return "normal" + + +########################################################## +########################################################## +# "New Diagnosis" Tasks +# +# See: https://arxiv.org/abs/2307.02028 +# Citation: Wornow et al. +# EHRSHOT: An EHR Benchmark for Few-Shot Evaluation of Foundation Models +# NeurIPS (2023). +########################################################## +########################################################## + +class FirstDiagnosisTimeHorizonCodeLabeler(TimeHorizonEventLabeler): + """Predict if patient will have their *first* diagnosis of `self.root_concept_code` in the next (1, 365) days. + + Make prediction at 11:59pm on day of discharge from inpatient admission. + + Excludes: + - Patients who have already had this diagnosis + """ + + root_concept_code: Optional[str] = None # OMOP concept code for outcome, e.g. "SNOMED/57054005" + + def __init__( + self, + ontology: femr.ontology.Ontology, + ): + assert ( + self.root_concept_code is not None + ), "Must specify `root_concept_code` for `FirstDiagnosisTimeHorizonCodeLabeler`" + self.ontology = ontology + self.outcome_codes = ontology.get_all_children(self.root_concept_code) + self.time_horizon: TimeHorizon = TimeHorizon(datetime.timedelta(minutes=1), datetime.timedelta(days=365)) + + def get_prediction_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return discharges that occur before first diagnosis of outcome as prediction times.""" + times: List[datetime.datetime] = [] + for __, discharge_time in get_inpatient_admission_discharge_times(patient, self.ontology): + prediction_time: datetime.datetime = move_datetime_to_end_of_day(discharge_time) + times.append(prediction_time) + times = sorted(list(set(times))) + + # Drop all times that occur after first diagnosis + valid_times: List[datetime.datetime] = [] + outcome_times: List[datetime.datetime] = self.get_outcome_times(patient) + if len(outcome_times) == 0: + return times + else: + first_diagnosis_time: datetime.datetime = min(outcome_times) + for t in times: + if t < first_diagnosis_time: + valid_times.append(t) + return valid_times + + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return the start times of this patient's events whose `code` is in `self.outcome_codes`.""" + times: List[datetime.datetime] = [] + for event in patient['events']: + for m in event['measurements']: + if m["code"] in self.outcome_codes: + times.append(event['time']) + return times + + def get_time_horizon(self) -> TimeHorizon: + return self.time_horizon + + def is_discard_censored_labels(self) -> bool: + return True + + def allow_same_time_labels(self) -> bool: + return False + + +class PancreaticCancerCodeLabeler(FirstDiagnosisTimeHorizonCodeLabeler): + # n = 200684 + root_concept_code = "SNOMED/372003004" + + +class CeliacDiseaseCodeLabeler(FirstDiagnosisTimeHorizonCodeLabeler): + # n = 60270 + root_concept_code = "SNOMED/396331005" + + +class LupusCodeLabeler(FirstDiagnosisTimeHorizonCodeLabeler): + # n = 176684 + root_concept_code = "SNOMED/55464009" + + +class AcuteMyocardialInfarctionCodeLabeler(FirstDiagnosisTimeHorizonCodeLabeler): + # n = 21982 + root_concept_code = "SNOMED/57054005" + + +class CTEPHCodeLabeler(FirstDiagnosisTimeHorizonCodeLabeler): + # n = 1433 + root_concept_code = "SNOMED/233947005" + + +class EssentialHypertensionCodeLabeler(FirstDiagnosisTimeHorizonCodeLabeler): + # n = 4644483 + root_concept_code = "SNOMED/59621000" + + +class HyperlipidemiaCodeLabeler(FirstDiagnosisTimeHorizonCodeLabeler): + # n = 3048320 + root_concept_code = "SNOMED/55822004" + + + +########################################################## +########################################################## +# "Chest X-Ray" Tasks +# +# See: https://arxiv.org/abs/2307.02028 +# Citation: Wornow et al. +# EHRSHOT: An EHR Benchmark for Few-Shot Evaluation of Foundation Models +# NeurIPS (2023). +########################################################## +########################################################## + + +CHEXPERT_LABELS = [ + "No Finding", + "Enlarged Cardiomediastinum", + "Cardiomegaly", + "Lung Lesion", + "Lung Opacity", + "Edema", + "Consolidation", + "Pneumonia", + "Atelectasis", + "Pneumothorax", + "Pleural Effusion", + "Pleural Other", + "Fracture", + "Support Devices", +] + + +class ChexpertLabeler(Labeler): + """CheXpert labeler. + + Multi-label classification task of patient's radiology reports. + Make prediction 24 hours before radiology note is recorded. + + Excludes: + - Radiology reports that are written <=24 hours of a patient's first event (i.e. `patient.events[0].start`) + """ + + def __init__( + self, + path_to_chexpert_csv: str, + ): + self.path_to_chexpert_csv = path_to_chexpert_csv + self.prediction_offset: datetime.timedelta = datetime.timedelta(hours=-24) + self.df_chexpert = pd.read_csv(self.path_to_chexpert_csv, sep="\t").sort_values(by=["start"], ascending=True) + + def label(self, patient: meds.Patient) -> List[meds.Label]: # type: ignore + labels: List[meds.Label] = [] + patient_start_time, _ = self.get_patient_start_end_times(patient) + df_patient = self.df_chexpert[self.df_chexpert["patient_id"] == patient.patient_id].sort_values( + by=["start"], ascending=True + ) + + for idx, row in df_patient.iterrows(): + label_time: datetime.datetime = datetime.datetime.fromisoformat(row["start"]) + prediction_time: datetime.datetime = label_time + self.prediction_offset + if prediction_time <= patient_start_time: + # Exclude radiology reports where our prediction time would be before patient's first timeline event + continue + + bool_labels = row[CHEXPERT_LABELS].astype(int).to_list() + label_string = "".join([str(x) for x in bool_labels]) + label_num: int = int(label_string, 2) + labels.append(meds.Label(patient_id=patient["patient_id"], prediction_time=prediction_time, integer_value=label_num)) + + return labels \ No newline at end of file diff --git a/src/femr/labelers/omop.py b/src/femr/labelers/omop.py index 4aa76da2..0b40297d 100644 --- a/src/femr/labelers/omop.py +++ b/src/femr/labelers/omop.py @@ -1,36 +1,236 @@ """meds.Labeling functions for OMOP data.""" from __future__ import annotations +from abc import abstractmethod import datetime -from typing import Any, Callable, List, Optional +from typing import Any, Callable, List, Optional, Set, Tuple, Union import meds +from femr.labelers.omop_inpatient import identity import femr.ontology -from .core import TimeHorizon, TimeHorizonEventLabeler +from .core import TimeHorizon, TimeHorizonEventLabeler, get_death_concepts + + +def get_visit_concepts() -> List[str]: + return ["Visit/IP", "Visit/OP"] + + +def get_inpatient_admission_concepts() -> List[str]: + return ["Visit/IP"] + + +def get_outpatient_visit_concepts() -> List[str]: + return ["Visit/OP"] + +def does_exist_event_within_time_range( + patient: meds.Patient, start: datetime.datetime, end: datetime.datetime, exclude_event_idxs: List[int] = [] +) -> bool: + """Return True if there is at least one event within the given time range for this patient. + If `exclude_event_idxs` is provided, exclude events with those indexes in `patient.events` from the search.""" + excluded = set(exclude_event_idxs) + for idx, e in enumerate(patient.events): + if idx in excluded: + continue + if start <= e.start <= end: + return True + return False + + +def get_visit_codes(ontology: femr.ontology.Ontology) -> Set[str]: + return get_femr_codes(ontology, get_visit_concepts(), is_ontology_expansion=True, is_silent_not_found_error=True) + + +def get_inpatient_admission_codes(ontology: femr.ontology.Ontology) -> Set[str]: + # Don't get children here b/c it adds noise (i.e. "Medicare Specialty/AO") + return get_femr_codes( + ontology, get_inpatient_admission_concepts(), is_ontology_expansion=False, is_silent_not_found_error=True + ) + + +def get_outpatient_visit_codes(ontology: femr.ontology.Ontology) -> Set[str]: + return get_femr_codes( + ontology, get_outpatient_visit_concepts(), is_ontology_expansion=False, is_silent_not_found_error=True + ) + + +def get_outpatient_visit_measurements(patient: meds.Patient, ontology: femr.ontology.Ontology) -> List[Tuple[datetime.datetime, meds.Measurement]]: + admission_codes: Set[str] = get_outpatient_visit_codes(ontology) + measurements: List[meds.Measurement] = [] + for e in patient['events']: + for m in e["measurements"]: + if m['code'] in admission_codes and m['omop_table'] == "visit_occurrence": + # Error checking + if m['start'] is None or m['end'] is None: + raise RuntimeError(f"Event {e} cannot have `None` as its `start` or `end` attribute.") + elif m['start'] > m['end']: + raise RuntimeError(f"Event {e} cannot have `start` after `end`.") + # Drop single point in time events + if m['start'] == m['end']: + continue + measurements.append((e['time'], m)) + return measurements + + +def get_inpatient_admission_measurements(patient: meds.Patient, + ontology: femr.ontology.Ontology) -> List[Tuple[datetime.datetime, meds.Measurement]]: + admission_codes: Set[str] = get_inpatient_admission_codes(ontology) + measurements: List[Tuple[datetime.datetime, meds.Measurement]] = [] + for e in patient["events"]: + for m in e["measurements"]: + if m['code'] in admission_codes and m['metadata']['table'] == "visit_occurrence": + # Error checking + if e['time'] is None or m['metadata']['end'] is None: + raise RuntimeError(f"Event {e} cannot have `None` as its `start` or `end` attribute.") + elif e['time'] > m['metadata']['end']: + raise RuntimeError(f"Event {e} cannot have `start` after `end`.") + # Drop single point in time events + if e['time'] == m['metadata']['end']: + continue + measurements.append((e['time'], m)) + return measurements + + +def get_inpatient_admission_discharge_times( + patient: meds.Patient, ontology: femr.ontology.Ontology +) -> List[Tuple[datetime.datetime, datetime.datetime]]: + """Return a list of all admission/discharge times for this patient.""" + measurements: List[Tuple[datetime.datetime, meds.Measurement]] = get_inpatient_admission_measurements(patient, ontology) + times: List[Tuple[datetime.datetime, datetime.datetime]] = [] + for (start, m) in measurements: + if m['metadata']['end'] is None: + raise RuntimeError(f"Event {m} cannot have `None` as its `end` attribute.") + if start > m['metadata']['end']: + raise RuntimeError(f"Event {m} cannot have `start` after `end`.") + times.append((start, m['metadata']['end'])) + return times -def identity(x: Any) -> Any: - return x +########################################################## +########################################################## +# Abstract classes derived from TimeHorizonEventLabeler +########################################################## +########################################################## -def get_death_concepts() -> List[str]: - return [ - meds.death_code, - ] +class WithinVisitLabeler(Labeler): + """ + The `WithinVisitLabeler` predicts whether or not a patient experiences a specific event + (as returned by `self.get_outcome_times()`) within each visit. + Very similar to `TimeHorizonLabeler`, except here we use visits themselves as our time horizon. -def move_datetime_to_end_of_day(date: datetime.datetime) -> datetime.datetime: - return date.replace(hour=23, minute=59, second=0) + Prediction Time: Start of each visit (adjusted by `self.prediction_adjustment_timedelta` if provided) + Time horizon: By end of visit + """ + def __init__( + self, + ontology: femr.ontology.Ontology, + visit_start_adjust_func: Optional[Callable] = None, + visit_end_adjust_func: Optional[Callable] = None, + ): + """The argument `visit_start_adjust_func` is a function that takes in a `datetime.datetime` + and returns a different `datetime.datetime`.""" + self.ontology: femr.ontology.Ontology = ontology + self.visit_start_adjust_func: Callable = ( + visit_start_adjust_func if visit_start_adjust_func is not None else identity # type: ignore + ) + self.visit_end_adjust_func: Callable = ( + visit_end_adjust_func if visit_end_adjust_func is not None else identity # type: ignore + ) -########################################################## -########################################################## -# Abstract classes derived from TimeHorizonEventLabeler -########################################################## -########################################################## + @abstractmethod + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return a list of all times when the patient experiences an outcome""" + return [] + @abstractmethod + def get_visit_measurements(self, patient: meds.Patient) -> List[meds.Measurement]: + """Return a list of all visits we want to consider (useful for limiting to inpatient visits).""" + return [] + + def label(self, patient: meds.Patient) -> List[meds.Label]: + """ + Label all visits returned by `self.get_visit_measurements()` with whether the patient + experiences an outcome in `self.outcome_codes` during each visit. + """ + visits: List[Tuple[datetime.datetime, meds.Measurement]] = self.get_visit_measurements(patient) + prediction_start_times: List[datetime.datetime] = [ + self.visit_start_adjust_func(start) for start, visit in visits + ] + prediction_end_times: List[datetime.datetime] = [self.visit_end_adjust_func(visit['metadata']['end']) for start, visit in visits] + outcome_times: List[datetime.datetime] = self.get_outcome_times(patient) + + # For each visit, check if there is an outcome which occurs within the (start, end) of the visit + results: List[meds.Label] = [] + curr_outcome_idx: int = 0 + for prediction_idx, (prediction_start, prediction_end) in enumerate( + zip(prediction_start_times, prediction_end_times) + ): + # Error checking + if curr_outcome_idx < len(outcome_times) and outcome_times[curr_outcome_idx] is None: + raise RuntimeError( + "Outcome times must be of type `datetime.datetime`, but value of `None`" + " provided for `self.get_outcome_times(patient)[{curr_outcome_idx}]" + ) + if prediction_start is None: + raise RuntimeError( + "Prediction start times must be of type `datetime.datetime`, but value of `None`" + " provided for `prediction_start_time`" + ) + if prediction_end is None: + raise RuntimeError( + "Prediction end times must be of type `datetime.datetime`, but value of `None`" + " provided for `prediction_end_time`" + ) + if prediction_start > prediction_end: + raise RuntimeError( + "Prediction start time must be before prediction end time, but `prediction_start_time`" + f" is `{prediction_start}` and `prediction_end_time` is `{prediction_end}`." + " Maybe you `visit_start_adjust_func()` or `visit_end_adjust_func()` in such a way that" + " the `start` time got pushed after the `end` time?" + " For reference, the original state time of this visit is" + f" `{visits[prediction_idx].start}` and the original end time is `{visits[prediction_idx].end}`." + f" This is for patient with patient_id `{patient['patient_id']}`." + ) + # Find the first outcome that occurs after this visit starts + # (this works b/c we assume visits are sorted by `start`) + while curr_outcome_idx < len(outcome_times) and outcome_times[curr_outcome_idx] < prediction_start: + # `curr_outcome_idx` is the idx in `outcome_times` that corresponds to the first + # outcome EQUAL or AFTER the visit for this prediction time starts (if one exists) + curr_outcome_idx += 1 + + # TRUE if an event occurs within the visit + is_outcome_occurs_in_time_horizon: bool = ( + ( + # ensure there is an outcome + # (needed in case there are 0 outcomes) + curr_outcome_idx + < len(outcome_times) + ) + and ( + # outcome occurs after visit starts + prediction_start + <= outcome_times[curr_outcome_idx] + ) + and ( + # outcome occurs before visit ends + outcome_times[curr_outcome_idx] + <= prediction_end + ) + ) + # Assume no censoring for visits + is_censored: bool = False + + if is_outcome_occurs_in_time_horizon: + results.append(meds.Label(patient_id=patient['patient_id'], prediction_time=prediction_start, boolean_value=True)) + elif not is_censored: + # Not censored + no outcome => FALSE + results.append(meds.Label(patient_id=patient['patient_id'], prediction_time=prediction_start, boolean_value=False)) + + return results class CodeLabeler(TimeHorizonEventLabeler): """Apply a label based on 1+ outcome_codes' occurrence(s) over a fixed time horizon.""" @@ -154,92 +354,4 @@ class LupusCodeLabeler(OMOPConceptCodeLabeler): meds.Label if patient is diagnosed with Lupus. """ - original_omop_concept_codes = ["SNOMED/55464009", "SNOMED/201436003"] - - -########################################################## -########################################################## -# Labeling functions derived from OMOPConceptCodeLabeler -########################################################## -########################################################## - - -class HypoglycemiaCodeLabeler(OMOPConceptCodeLabeler): - """Apply a label for whether a patient has at 1+ explicitly - coded occurrence(s) of Hypoglycemia in `time_horizon`.""" - - # fmt: off - original_omop_concept_codes = [ - 'SNOMED/267384006', 'SNOMED/421725003', 'SNOMED/719216001', - 'SNOMED/302866003', 'SNOMED/237633009', 'SNOMED/120731000119103', - 'SNOMED/190448007', 'SNOMED/230796005', 'SNOMED/421437000', - 'SNOMED/52767006', 'SNOMED/237637005', 'SNOMED/84371000119108' - ] - # fmt: on - - -class AKICodeLabeler(OMOPConceptCodeLabeler): - """Apply a label for whether a patient has at 1+ explicitly - coded occurrence(s) of AKI in `time_horizon`.""" - - # fmt: off - original_omop_concept_codes = [ - 'SNOMED/14669001', 'SNOMED/298015003', 'SNOMED/35455006', - ] - # fmt: on - - -class AnemiaCodeLabeler(OMOPConceptCodeLabeler): - """Apply a label for whether a patient has at 1+ explicitly - coded occurrence(s) of Anemia in `time_horizon`.""" - - # fmt: off - original_omop_concept_codes = [ - 'SNOMED/271737000', 'SNOMED/713496008', 'SNOMED/713349004', 'SNOMED/767657005', - 'SNOMED/111570005', 'SNOMED/691401000119104', 'SNOMED/691411000119101', - ] - # fmt: on - - -class HyperkalemiaCodeLabeler(OMOPConceptCodeLabeler): - """Apply a label for whether a patient has at 1+ explicitly - coded occurrence(s) of Hyperkalemia in `time_horizon`.""" - - # fmt: off - original_omop_concept_codes = [ - 'SNOMED/14140009', - ] - # fmt: on - - -class HyponatremiaCodeLabeler(OMOPConceptCodeLabeler): - """Apply a label for whether a patient has at 1+ explicitly - coded occurrence(s) of Hyponatremia in `time_horizon`.""" - - # fmt: off - original_omop_concept_codes = [ - 'SNOMED/267447008', 'SNOMED/89627008' - ] - # fmt: on - - -class ThrombocytopeniaCodeLabeler(OMOPConceptCodeLabeler): - """Apply a label for whether a patient has at 1+ explicitly - coded occurrence(s) of Thrombocytopenia in `time_horizon`.""" - - # fmt: off - original_omop_concept_codes = [ - 'SNOMED/267447008', 'SNOMED/89627008', - ] - # fmt: on - - -class NeutropeniaCodeLabeler(OMOPConceptCodeLabeler): - """Apply a label for whether a patient has at 1+ explicitly - coded occurrence(s) of Neutkropenia in `time_horizon`.""" - - # fmt: off - original_omop_concept_codes = [ - 'SNOMED/165517008', - ] - # fmt: on + original_omop_concept_codes = ["SNOMED/55464009", "SNOMED/201436003"] \ No newline at end of file diff --git a/src/femr/labelers/omop_inpatient.py b/src/femr/labelers/omop_inpatient.py new file mode 100644 index 00000000..6e7ee825 --- /dev/null +++ b/src/femr/labelers/omop_inpatient.py @@ -0,0 +1,242 @@ +"""Labeling functions for OMOP data based on lab values.""" +from __future__ import annotations + +import datetime +from abc import abstractmethod +from typing import Any, Callable, List, Optional, Set + +import meds +import femr.ontology + +from .core import Labeler, TimeHorizon, TimeHorizonEventLabeler, identity, move_datetime_to_end_of_day +from .omop import ( + WithinVisitLabeler, + get_death_concepts, + get_inpatient_admission_discharge_times, + get_inpatient_admission_measurements, +) + + +class WithinInpatientVisitLabeler(WithinVisitLabeler): + """ + The `WithinInpatientVisitLabeler` predicts whether or not a patient experiences + a specific event within each INPATIENT visit. + + The only difference from `WithinVisitLabeler` is that these visits are restricted to only INPATIENT visits. + + Prediction Time: Start of each INPATIENT visit (adjusted by `self.prediction_time_adjustment_func()` if provided) + """ + + def __init__( + self, + ontology: femr.ontology.Ontology, + visit_start_adjust_func: Optional[Callable] = None, + visit_end_adjust_func: Optional[Callable] = None, + ): + """The argument `visit_start_adjust_func` is a function that takes in a `datetime.datetime` + and returns a different `datetime.datetime`.""" + super().__init__( + ontology=ontology, + visit_start_adjust_func=visit_start_adjust_func if visit_start_adjust_func else identity, + visit_end_adjust_func=visit_end_adjust_func if visit_end_adjust_func else identity, + ) + + @abstractmethod + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + return [] + + def get_visit_measurements(self, patient: meds.Patient) -> List[meds.Measurement]: + return get_inpatient_admission_measurements(patient, self.ontology) + + +class DummyAdmissionDischargeLabeler(Labeler): + """Generate a placeholder Label at every admission and discharge time for this patient.""" + + def __init__(self, ontology: femr.ontology.Ontology, prediction_time_adjustment_func: Callable = identity): + self.ontology: femr.ontology.Ontology = ontology + self.prediction_time_adjustment_func: Callable = prediction_time_adjustment_func + + def label(self, patient: meds.Patient) -> List[meds.Label]: + labels: List[meds.Label] = [] + for admission_time, discharge_time in get_inpatient_admission_discharge_times(patient, self.ontology): + labels.append(meds.Label(patient_id=patient["patient_id"], prediction_time=self.prediction_time_adjustment_func(admission_time), boolean_value=True)) + labels.append(meds.Label(patient_id=patient["patient_id"], prediction_time=self.prediction_time_adjustment_func(discharge_time), boolean_value=True)) + return labels + + +class InpatientReadmissionLabeler(TimeHorizonEventLabeler): + """ + This labeler is designed to predict whether a patient will be readmitted within `time_horizon` + It explicitly does not try to deal with categorizing admissions as "unexpected" or not and is thus + not comparable to other work. + + Prediction time: At discharge from an inpatient admission. Defaults to shifting prediction time + to the end of the day. + Time horizon: Interval of time after discharge of length `time_horizon` + Label: TRUE if patient has an inpatient admission within `time_horizon` + + Defaults to 30-day readmission labeler, + i.e. `time_horizon = TimeHorizon(1 minutes, 30 days)` + """ + + def __init__( + self, + ontology: femr.ontology.Ontology, + time_horizon: TimeHorizon = TimeHorizon( + start=datetime.timedelta(minutes=1), end=datetime.timedelta(days=30) + ), # type: ignore + prediction_time_adjustment_func: Callable = move_datetime_to_end_of_day, + ): + self.ontology: femr.ontology.Ontology = ontology + self.time_horizon: TimeHorizon = time_horizon + self.prediction_time_adjustment_func = prediction_time_adjustment_func + + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return the start times of inpatient admissions.""" + times: List[datetime.datetime] = [] + for admission_time, __ in get_inpatient_admission_discharge_times(patient, self.ontology): + times.append(admission_time) + return times + + def get_prediction_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return end of admission as prediction timm.""" + times: List[datetime.datetime] = [] + admission_times: List[datetime.datetime] = self.get_outcome_times(patient) + for __, discharge_time in get_inpatient_admission_discharge_times(patient, self.ontology): + prediction_time: datetime.datetime = self.prediction_time_adjustment_func(discharge_time) + # Ignore patients who are readmitted the same day after they were discharged b/c of data leakage + for at in admission_times: + if at.date() == prediction_time.date() and at >= prediction_time: + continue + times.append(prediction_time) + times = sorted(list(set(times))) + return times + + def get_time_horizon(self) -> TimeHorizon: + return self.time_horizon + + +class InpatientLongAdmissionLabeler(Labeler): + """ + This labeler predicts whether or not a patient will be admitted for a long time (defined + as `admission.end - admission.start >= self.long_time`) during an INPATIENT visit. + + Prediction time: At time of INPATIENT admission. + Time horizon: Till the end of the visit + Label: TRUE if visit length is >= `long_time` (i.e. `visit.end - visit.start >= long_time`) + + Defaults to 7-day long length-of-stay (LOS) + i.e. `long_time = 7 days` + """ + + def __init__( + self, + ontology: femr.ontology.Ontology, + long_time: datetime.timedelta = datetime.timedelta(days=7), + prediction_time_adjustment_func: Optional[Callable] = None, + ): + self.ontology: femr.ontology.Ontology = ontology + self.long_time: datetime.timedelta = long_time + self.prediction_time_adjustment_func: Callable = ( + prediction_time_adjustment_func if prediction_time_adjustment_func is not None else identity # type: ignore + ) + + def label(self, patient: meds.Patient) -> List[meds.Label]: + """ + Label all admissions with admission length > `self.long_time` + Exclude admission if time of discharge or death < prediction time + """ + labels: List[meds.Label] = [] + for admission_time, discharge_time in get_inpatient_admission_discharge_times(patient, self.ontology): + is_long_admission: bool = (discharge_time - admission_time) >= self.long_time + prediction_time: datetime.datetime = self.prediction_time_adjustment_func(admission_time) + + # exclude if discharge or death occurred before prediction time + death_concepts: Set[str] = self.ontology.get_all_children(get_death_concepts()) + death_times: List[datetime.datetime] = [] + for e in patient.events: + if e.code in death_concepts: + death_times.append(e.start) + + if prediction_time > discharge_time: + continue + + if death_times and prediction_time > min(death_times): + continue + + labels.append(meds.Label(patient_id=patient["patient_id"], prediction_time=prediction_time, boolean_value=is_long_admission)) + return labels + +class InpatientMortalityLabeler(WithinInpatientVisitLabeler): + """ + The inpatient labeler predicts whether or not a patient will die within the current INPATIENT admission. + + Prediction time: Defaults to 11:59:59pm on the day of the INPATIENT admission. + Time horizon: (1 minute, end of admission) [note this time horizon varies by visit] + Label: TRUE if patient dies within visit + """ + + def __init__( + self, + ontology: femr.ontology.Ontology, + visit_start_adjust_func: Callable = move_datetime_to_end_of_day, + visit_end_adjust_func: Callable = identity, + ): + femr_codes: Set[str] = ontology.get_all_children(get_death_concepts()) + self.outcome_codes: Set[str] = femr_codes + super().__init__( + ontology=ontology, + visit_start_adjust_func=visit_start_adjust_func, + visit_end_adjust_func=visit_end_adjust_func, + ) + + def get_outcome_times(self, patient: meds.Patient) -> List[datetime.datetime]: + """Return time of any event with a code in `self.outcome_codes`.""" + times: List[datetime.datetime] = [] + for e in patient.events: + if e.code in self.outcome_codes: + times.append(e.start) + return times + + def label(self, patient: meds.Patient) -> List[meds.Label]: + """ + Label all inpatient mortality with prediction_time <= death_time <= discharge + Exclude admission if time of discharge or death < prediction time + """ + visits: List[Event] = self.get_visit_events(patient) + prediction_start_times: List[datetime.datetime] = [ + self.visit_start_adjust_func(visit.start) for visit in visits + ] + prediction_end_times: List[datetime.datetime] = [self.visit_end_adjust_func(visit.end) for visit in visits] + outcome_times: List[datetime.datetime] = self.get_outcome_times(patient) + death_time = min(outcome_times) if outcome_times else None + results: List[meds.Label] = [] + + # For each visit, check if there is an outcome which occurs within the (start, end) of the visit + for prediction_start, prediction_end in zip(prediction_start_times, prediction_end_times): + # exclude if prediction time > discharge time + if prediction_start >= prediction_end: + continue + + # exclude if deathtime > discharge time + if death_time is not None and prediction_start > death_time: + continue + + # TRUE if an event occurs within the visit + is_outcome_occurs_in_time_horizon: bool = ( + death_time is not None + and ( + # outcome occurs after visit starts + prediction_start + <= death_time + ) + and ( + # outcome occurs before visit ends + death_time + <= prediction_end + ) + ) + + results.append(meds.Label(patient_id=patient["patient_id"], prediction_time=prediction_start, boolean_value=is_outcome_occurs_in_time_horizon)) + + return results \ No newline at end of file diff --git a/src/femr/labelers/omop_labs.py b/src/femr/labelers/omop_labs.py new file mode 100644 index 00000000..c5393d86 --- /dev/null +++ b/src/femr/labelers/omop_labs.py @@ -0,0 +1,186 @@ +"""Labeling functions for OMOP data based on lab values.""" +from __future__ import annotations + +import datetime +from abc import abstractmethod +from typing import List, Optional, Set + +import meds +import femr.ontology + +from .core import ( + Labeler, + OMOPConceptCodeLabeler, +) + +from femr.labelers.omop import get_femr_codes + +########################################################## +########################################################## +# Labelers based on Lab Values. +# +# The difference between these Labelers and the ones based on codes +# is that these Labelers are based on lab values, not coded +# diagnoses. Thus, they may catch more cases of a given +# condition due to under-coding, but they are also more +# likely to be noisy. +########################################################## +########################################################## + + +class InstantLabValueLabeler(Labeler): + """Apply a multi-class label for the outcome of a lab test. + + Prediction Time: Immediately before lab result is returned (i.e. 1 minute before) + Time Horizon: The next immediate result for this lab test + Label: Severity level of lab + + Excludes: + - Labels that occur at the same exact time as the very first event in a patient's history + """ + + # parent OMOP concept codes, from which all the outcomes are derived (as children in our ontology) + original_omop_concept_codes: List[str] = [] + + def __init__( + self, + ontology: femr.ontology.Ontology, + ): + self.ontology: femr.ontology.Ontology = ontology + self.outcome_codes: Set[str] = get_femr_codes( + ontology, + self.original_omop_concept_codes, + is_ontology_expansion=True, + ) + + def label(self, patient: meds.Patient, is_show_warnings: bool = False) -> List[meds.Label]: + labels: List[meds.Label] = [] + for e in patient["events"]: + if patient["events"][0]["time"] == e["time"]: + # Ignore events that occur at the same time as the first event in the patient's history + continue + for m in e["measurements"]: + if m["code"] in self.outcome_codes: + # This is an outcome event + if m['text_value'] is not None: + try: + # `e.unit` is string of form "mg/dL", "ounces", etc. + # TODO -- where is e.value and e.unit stored? + label: int = self.label_to_int(self.value_to_label(m['text_value'], m['metadata']['unit'])) + prediction_time: datetime.datetime = e["time"] - datetime.timedelta(minutes=1) + labels.append(meds.Label(patient_id=patient["patient_id"], prediction_time=prediction_time, integer_value=label)) + except Exception as exception: + if is_show_warnings: + print( + f"Warning: Error parsing value='{m['text_value']}' with metadata='{m['metadata']}'" + f" for code='{m['code']}' @ {e['time']} for patient_id='{patient['patient_id']}'" + f" | Exception: {exception}" + ) + return labels + + def label_to_int(self, label: str) -> int: + if label == "normal": + return 0 + elif label == "mild": + return 1 + elif label == "moderate": + return 2 + elif label == "severe": + return 3 + raise ValueError(f"Invalid label without a corresponding int: {label}") + + @abstractmethod + def value_to_label(self, raw_value: str, unit: Optional[str]) -> str: + """Convert `value` to a string label: "mild", "moderate", "severe", or "normal". + NOTE: Some units have the form 'mg/dL (See scan or EMR data for detail)', so you + need to use `.startswith()` to check for the unit you want. + """ + return "normal" + + +########################################################## +########################################################## +# Labeling functions derived from OMOPConceptCodeLabeler +########################################################## +########################################################## + + +class HypoglycemiaCodeLabeler(OMOPConceptCodeLabeler): + """Apply a label for whether a patient has at 1+ explicitly + coded occurrence(s) of Hypoglycemia in `time_horizon`.""" + + # fmt: off + original_omop_concept_codes = [ + 'SNOMED/267384006', 'SNOMED/421725003', 'SNOMED/719216001', + 'SNOMED/302866003', 'SNOMED/237633009', 'SNOMED/120731000119103', + 'SNOMED/190448007', 'SNOMED/230796005', 'SNOMED/421437000', + 'SNOMED/52767006', 'SNOMED/237637005', 'SNOMED/84371000119108' + ] + # fmt: on + + +class AKICodeLabeler(OMOPConceptCodeLabeler): + """Apply a label for whether a patient has at 1+ explicitly + coded occurrence(s) of AKI in `time_horizon`.""" + + # fmt: off + original_omop_concept_codes = [ + 'SNOMED/14669001', 'SNOMED/298015003', 'SNOMED/35455006', + ] + # fmt: on + + +class AnemiaCodeLabeler(OMOPConceptCodeLabeler): + """Apply a label for whether a patient has at 1+ explicitly + coded occurrence(s) of Anemia in `time_horizon`.""" + + # fmt: off + original_omop_concept_codes = [ + 'SNOMED/271737000', 'SNOMED/713496008', 'SNOMED/713349004', 'SNOMED/767657005', + 'SNOMED/111570005', 'SNOMED/691401000119104', 'SNOMED/691411000119101', + ] + # fmt: on + + +class HyperkalemiaCodeLabeler(OMOPConceptCodeLabeler): + """Apply a label for whether a patient has at 1+ explicitly + coded occurrence(s) of Hyperkalemia in `time_horizon`.""" + + # fmt: off + original_omop_concept_codes = [ + 'SNOMED/14140009', + ] + # fmt: on + + +class HyponatremiaCodeLabeler(OMOPConceptCodeLabeler): + """Apply a label for whether a patient has at 1+ explicitly + coded occurrence(s) of Hyponatremia in `time_horizon`.""" + + # fmt: off + original_omop_concept_codes = [ + 'SNOMED/267447008', 'SNOMED/89627008' + ] + # fmt: on + + +class ThrombocytopeniaCodeLabeler(OMOPConceptCodeLabeler): + """Apply a label for whether a patient has at 1+ explicitly + coded occurrence(s) of Thrombocytopenia in `time_horizon`.""" + + # fmt: off + original_omop_concept_codes = [ + 'SNOMED/267447008', 'SNOMED/89627008', + ] + # fmt: on + + +class NeutropeniaCodeLabeler(OMOPConceptCodeLabeler): + """Apply a label for whether a patient has at 1+ explicitly + coded occurrence(s) of Neutkropenia in `time_horizon`.""" + + # fmt: off + original_omop_concept_codes = [ + 'SNOMED/165517008', + ] + # fmt: on \ No newline at end of file