diff --git a/pyhealth/datasets/__init__.py b/pyhealth/datasets/__init__.py index f2be0499e..0a7bc8484 100644 --- a/pyhealth/datasets/__init__.py +++ b/pyhealth/datasets/__init__.py @@ -74,6 +74,8 @@ def __init__(self, *args, **kwargs): from .support2 import Support2Dataset from .tcga_prad import TCGAPRADDataset from .splitter import ( + sample_oversample, + sample_weighted, sample_balanced, split_by_patient, split_by_patient_conformal, diff --git a/pyhealth/datasets/splitter.py b/pyhealth/datasets/splitter.py index b2ea98854..84cb86acc 100644 --- a/pyhealth/datasets/splitter.py +++ b/pyhealth/datasets/splitter.py @@ -693,3 +693,121 @@ def split_by_sample_conformal( cal_dataset = dataset.subset(cal_index) # type: ignore test_dataset = dataset.subset(test_index) # type: ignore return train_dataset, val_dataset, cal_dataset, test_dataset + + +def sample_oversample( + dataset: SampleDataset, + ratio: float = 1.0, + seed: Optional[int] = None, + label_key: str = "label", +) -> SampleDataset: + """Oversample minority (positive) class by duplicating with replacement. + + Keeps ALL negative samples and duplicates positives until n_pos ≈ n_neg / ratio. + Unlike undersampling, no negatives are discarded — full negative diversity is preserved. + Trade-off: frozen-encoder models may overfit on duplicate positive embeddings. + + Args: + dataset: Dataset with ``patient_to_index`` populated. + ratio: Target negatives-per-positive (e.g., 1.0 → equal pos/neg; 3.0 → 3:1). + Values ≤0 are invalid. If the existing ratio already meets the target, + the dataset is returned unmodified. + seed: Optional RNG seed for reproducible positive sampling. + label_key: Key to use for accessing the label field in each sample. + + Returns: + A new ``SampleDataset`` containing all negatives plus oversampled positives, + with refreshed ``patient_to_index`` and ``record_to_index`` mappings. + """ + if ratio <= 0: + raise ValueError("ratio must be positive") + + rng = np.random.default_rng(seed) + + pos_indices: List[int] = [] + neg_indices: List[int] = [] + + for idx in range(len(dataset)): + label = _label_to_int(dataset[idx][label_key]) + if label == 1: + pos_indices.append(idx) + else: + neg_indices.append(idx) + + if not pos_indices or not neg_indices: + return dataset + + target_pos = max(len(pos_indices), int(round(len(neg_indices) / ratio))) + if target_pos <= len(pos_indices): + return dataset + + extra_needed = target_pos - len(pos_indices) + extra_pos = list(rng.choice(pos_indices, size=extra_needed, replace=True)) + + keep_indices = pos_indices + extra_pos + neg_indices + + oversampled = dataset.subset(keep_indices) # type: ignore + + oversampled.patient_to_index = {} + oversampled.record_to_index = {} + for i in range(len(oversampled)): + sample = oversampled[i] + pid = sample.get("patient_id") + rid = sample.get("record_id", sample.get("visit_id")) + if pid is not None: + oversampled.patient_to_index.setdefault(pid, []).append(i) + if rid is not None: + oversampled.record_to_index.setdefault(rid, []).append(i) + + return oversampled + + +def sample_weighted( + dataset: SampleDataset, + seed: Optional[int] = None, + label_key: str = "label", +) -> SampleDataset: + """Create one fixed, approximately class-balanced bootstrap sample. + + Draws ``len(dataset)`` indices with replacement using class-proportional + probabilities (p[i] = 1/class_count[label[i]], normalised). The resampled + dataset has the same length as the original, but roughly + balanced class frequencies. The sampled multiset is fixed; use + ``get_weighted_dataloader`` for a fresh sampler draw each epoch. + + Args: + dataset: Dataset whose samples have a binary label field. + seed: Optional RNG seed for reproducibility. + label_key: Key to use for accessing the label field in each sample. + + Returns: + A new ``SampleDataset`` containing the resampled indices (with + refreshed ``patient_to_index`` and ``record_to_index``). + """ + rng = np.random.default_rng(seed) + + labels = np.array([_label_to_int(dataset[i][label_key]) for i in range(len(dataset))]) + if set(np.unique(labels).tolist()) != {0, 1}: + raise ValueError( + "sample_weighted: dataset has a missing class — cannot compute weights" + ) + class_counts = np.bincount(labels, minlength=2) + class_weights = 1.0 / class_counts.astype(float) + sample_probs = class_weights[labels] + sample_probs /= sample_probs.sum() + + chosen = list(rng.choice(len(dataset), size=len(dataset), replace=True, p=sample_probs)) + resampled = dataset.subset(chosen) # type: ignore + + resampled.patient_to_index = {} + resampled.record_to_index = {} + for i in range(len(resampled)): + sample = resampled[i] + pid = sample.get("patient_id") + rid = sample.get("record_id", sample.get("visit_id")) + if pid is not None: + resampled.patient_to_index.setdefault(pid, []).append(i) + if rid is not None: + resampled.record_to_index.setdefault(rid, []).append(i) + + return resampled diff --git a/pyhealth/processors/stagenet_processor.py b/pyhealth/processors/stagenet_processor.py index f9b242f0d..ce3714d0b 100644 --- a/pyhealth/processors/stagenet_processor.py +++ b/pyhealth/processors/stagenet_processor.py @@ -193,9 +193,9 @@ def process( def _encode_codes(self, codes: List[str]) -> torch.Tensor: """Encode flat code list to indices.""" - # Handle empty code list - return single padding token + # Handle empty code list — zero events, not a fake pad token. if len(codes) == 0: - return torch.tensor([self.code_vocab[""]], dtype=torch.long) + return torch.zeros((0,), dtype=torch.long) indices = [] for code in codes: @@ -213,10 +213,9 @@ def _encode_nested_codes(self, nested_codes: List[List[str]]) -> torch.Tensor: assert self._max_nested_len is not None, "Max nested length must be set during fit()" # Handle empty nested codes (no visits/events) - # Return single padding token with shape (1, max_len) if len(nested_codes) == 0: - pad_token = self.code_vocab[""] - return torch.tensor([[pad_token] * self._max_nested_len], dtype=torch.long) + max_len = self._max_nested_len if self._max_nested_len is not None else 1 + return torch.zeros((0, max_len), dtype=torch.long) encoded_sequences = [] # Use global max length determined during fit diff --git a/pyhealth/processors/time_image_processor.py b/pyhealth/processors/time_image_processor.py index 421998d07..efa03ddc4 100644 --- a/pyhealth/processors/time_image_processor.py +++ b/pyhealth/processors/time_image_processor.py @@ -279,7 +279,6 @@ def process( Raises: ValueError: If image_paths and time_diffs have different lengths. - ValueError: If image_paths is empty. FileNotFoundError: If any image file does not exist. """ image_paths, time_diffs = value @@ -291,7 +290,19 @@ def process( f"match." ) if len(image_paths) == 0: - raise ValueError("image_paths must be non-empty.") + if self.n_channels is not None: + c = self.n_channels + elif self.mode == "L": + c = 1 + elif self.mode == "RGBA": + c = 4 + else: + c = 3 + images = torch.zeros( + (0, c, self.image_size, self.image_size), dtype=torch.float32 + ) + timestamps = torch.zeros((0,), dtype=torch.float32) + return images, timestamps, "image" paired = sorted(zip(time_diffs, image_paths), key=lambda x: x[0]) diff --git a/pyhealth/processors/tuple_time_text_processor.py b/pyhealth/processors/tuple_time_text_processor.py index 28b21a1c5..eab3554df 100644 --- a/pyhealth/processors/tuple_time_text_processor.py +++ b/pyhealth/processors/tuple_time_text_processor.py @@ -5,7 +5,6 @@ from . import register_processor logger = logging.getLogger(__name__) -_MISSING_TEXT_TOKEN = "[MISSING_TEXT]" @register_processor("tuple_time_text") class TupleTimeTextProcessor(TemporalFeatureProcessor): @@ -109,18 +108,18 @@ def process(self, value: Tuple[List[str], List[float]]) -> Union[Tuple[List[str] cleaned_texts.append(text) cleaned_times.append(t) - # Fast tokenizer path crashes on empty batches; force a single - # missingness token when all notes are empty/malformed. - if len(cleaned_texts) == 0: - cleaned_texts = [_MISSING_TEXT_TOKEN] - cleaned_times = [0.0] - texts = cleaned_texts time_diffs = cleaned_times time_tensor = torch.tensor(time_diffs, dtype=torch.float32) if self.tokenizer is not None: - # Tokenize the list of texts + # Fast tokenizers crash on tokenizer([]). Build empty tensors + # ourselves so a patient with no notes is zero events, not a + # fake "[MISSING_TEXT]" row whose BERT embedding is a constant + # the classifier can use as a mortality feature. + if len(texts) == 0: + empty = torch.zeros((0, 1), dtype=torch.long) + return empty, empty.clone(), empty.clone(), time_tensor, self.type_tag encoded = self.tokenizer( texts, padding="max_length" if self.padding else False, diff --git a/pyhealth/tasks/__init__.py b/pyhealth/tasks/__init__.py index cadb479ce..76caf4e24 100644 --- a/pyhealth/tasks/__init__.py +++ b/pyhealth/tasks/__init__.py @@ -46,7 +46,6 @@ ) from .multimodal_mimic4 import ( ClinicalNotesMIMIC4, - ClinicalNotesICDLabsMIMIC4, NotesLabsMIMIC4, ) from .patient_linkage import patient_linkage_mimic3_fn @@ -73,6 +72,7 @@ from .multimodal_mimic4 import ( ClinicalNotesMIMIC4, ClinicalNotesICDLabsMIMIC4, + LabsOnlyMIMIC4, ClinicalNotesICDLabsCXRMIMIC4, ) from .patient_linkage_mimic3 import PatientLinkageMIMIC3Task diff --git a/pyhealth/tasks/multimodal_mimic4.py b/pyhealth/tasks/multimodal_mimic4.py index 21147303c..4e2a0300a 100644 --- a/pyhealth/tasks/multimodal_mimic4.py +++ b/pyhealth/tasks/multimodal_mimic4.py @@ -104,6 +104,10 @@ def __init__( window_hours: Optional[float] = None, ): self.window_hours = window_hours + # The task cache key is uuid5 over {**vars(task), schemas}, so a code-only + # fix leaves every existing cache serving superseded samples in silence. + # Bump whenever emitted data changes. + self.emitted_data_version = 3 @staticmethod def _clean_text(text: Optional[str]) -> Optional[str]: @@ -169,6 +173,35 @@ def _compute_effective_window( return effective_start, effective_end + def _admission_window_end( + self, + admission_time: datetime, + admission_dischtime: datetime, + ) -> datetime: + """End of the observation window for ONE admission. + + Two defects are fixed here. + + First, callers passed ``admission_dischtime`` directly, so ``window_hours`` + was inert and labs were collected across the whole stay. For a mortality + label that reads the outcome: labs drawn hours before death are close to + deterministic. + + Second, ``_compute_effective_window`` anchors on the FIRST admission, so + reusing its ``effective_end`` for every admission gives later admissions a + span that ends before it starts. They collect nothing, and the task then + injects a placeholder row for each, encoding the patient's future + admission count in the sequence length. Re-anchoring per admission is the + faithful reading of "window_hours from admission". + + Clamped to discharge so an admission shorter than the window cannot reach + past its own end into the next stay. + """ + if self.window_hours is None: + return admission_dischtime + end = admission_time + timedelta(hours=self.window_hours) + return min(end, admission_dischtime) if admission_dischtime else end + def _build_admissions_to_process(self, patient: Any) -> Tuple[List[Any], int]: """Build admissions to process and derive mortality label. @@ -228,8 +261,7 @@ def _collect_labs( Returns: Tuple of (lab_times, lab_values, lab_masks). ``lab_masks`` is a parallel boolean tensor where ``True`` means observed and ``False`` - means imputed with 0.0. Falls back to a single missing placeholder - row when no valid lab events are found. + means imputed with 0.0. An admission with no labs returns empty lists. """ try: import polars as pl @@ -287,17 +319,7 @@ def _collect_labs( ) lab_values.append(lab_vector) lab_masks.append(lab_mask) - else: # If missing lab for a given admission - lab_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.LAB_CATEGORY_NAMES) - ) - lab_masks.append([False] * len(self.LAB_CATEGORY_NAMES)) - lab_times.append(self.MISSING_FLOAT_TOKEN) - if len(lab_values) == 0: # If missing lab for ALL admissions - lab_values.append([self.MISSING_FLOAT_TOKEN] * len(self.LAB_CATEGORY_NAMES)) - lab_masks.append([False] * len(self.LAB_CATEGORY_NAMES)) - lab_times.append(self.MISSING_FLOAT_TOKEN) return lab_times, lab_values, lab_masks def _collect_vitals( @@ -311,7 +333,7 @@ def _collect_vitals( Returns: Tuple of (vital_times, vital_values, vital_masks). vital_masks is parallel boolean: True = observed, False = imputed 0.0. - Falls back to a single missing-placeholder row when no valid vitals are found. + An admission with no vitals returns empty lists. """ try: import polars as pl @@ -370,13 +392,6 @@ def _collect_vitals( vital_values.append(vital_vector) vital_masks.append(vital_mask) - if len(vital_values) == 0: - vital_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.VITAL_CATEGORY_NAMES) - ) - vital_masks.append([False] * len(self.VITAL_CATEGORY_NAMES)) - vital_times.append(self.MISSING_FLOAT_TOKEN) - return vital_times, vital_values, vital_masks def _collect_notes( @@ -406,9 +421,8 @@ def _collect_notes( with no matching sections are dropped entirely. Returns: - Tuple of (texts, hours_from_admission). Falls back to - ``([MISSING_TEXT_TOKEN], [MISSING_FLOAT_TOKEN])`` when the events - list is empty. + Tuple of (texts, hours_from_admission). Empty when no notes + survive cleaning; the processor then emits zero events. """ notes = patient.get_events( event_type=note_event_type, @@ -538,13 +552,6 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: all_radiology_texts.extend(radiology_texts) all_radiology_times_from_admission.extend(radiology_times) - if not all_discharge_texts: - all_discharge_texts = [self.MISSING_TEXT_TOKEN] - all_discharge_times_from_admission = [self.MISSING_FLOAT_TOKEN] - if not all_radiology_texts: - all_radiology_texts = [self.MISSING_TEXT_TOKEN] - all_radiology_times_from_admission = [self.MISSING_FLOAT_TOKEN] - discharge_note_times_from_admission = ( all_discharge_texts, all_discharge_times_from_admission, @@ -554,17 +561,6 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: all_radiology_times_from_admission, ) - if len(all_discharge_texts) == 0: - discharge_note_times_from_admission = ( - [self.MISSING_TEXT_TOKEN], - [self.MISSING_FLOAT_TOKEN], - ) - if len(all_radiology_texts) == 0: - radiology_note_times_from_admission = ( - [self.MISSING_TEXT_TOKEN], - [self.MISSING_FLOAT_TOKEN], - ) - single_patient_longitudinal_record = { "patient_id": patient.patient_id, "discharge_note_times": discharge_note_times_from_admission, @@ -733,9 +729,6 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: ).total_seconds() / 3600.0 all_icd_codes.append(visit_icd_codes) all_icd_times.append(time_from_previous) - else: # Add missingness token if there are no ICD diagnosis/inpatient procedure codes - all_icd_codes.append([self.MISSING_CODE_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) previous_admission_time = admission_time @@ -743,25 +736,14 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: lab_times, lab_values, lab_masks = self._collect_labs( patient=patient, admission_time=admission_time, - end_time=admission_dischtime, + end_time=self._admission_window_end( + admission_time, admission_dischtime + ), ) all_lab_times.extend(lab_times) all_lab_values.extend(lab_values) all_lab_masks.extend(lab_masks) - if len(all_lab_values) == 0: # If missing lab for ALL admissions - all_lab_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.LAB_CATEGORY_NAMES) - ) - all_lab_masks.append([False] * len(self.LAB_CATEGORY_NAMES)) - all_lab_times.append(self.MISSING_FLOAT_TOKEN) - - # If all admissions were skipped before ICD collection, ensure a - # single placeholder step so StageNetProcessor does not emit None time. - if len(all_icd_codes) == 0: - all_icd_codes.append([self.MISSING_CODE_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) - discharge_note_times_from_admission = ( all_discharge_texts, all_discharge_times_from_admission, @@ -771,20 +753,6 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: all_radiology_times_from_admission, ) - # Per-admission note fallback happens inside _collect_notes(). - # This final guard handles the edge case where every admission was - # skipped before _collect_notes() was reached. - if len(all_discharge_texts) == 0: - discharge_note_times_from_admission = ( - [self.MISSING_TEXT_TOKEN], - [self.MISSING_FLOAT_TOKEN], - ) - if len(all_radiology_texts) == 0: - radiology_note_times_from_admission = ( - [self.MISSING_TEXT_TOKEN], - [self.MISSING_FLOAT_TOKEN], - ) - single_patient_longitudinal_record = { "patient_id": patient.patient_id, "discharge_note_times": discharge_note_times_from_admission, @@ -800,121 +768,11 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: return [single_patient_longitudinal_record] -class ICDLabsMIMIC4(BaseMultimodalMIMIC4Task): - """Task for ICD codes + lab values mortality prediction using MIMIC-IV. - - A notes-free variant of ``ClinicalNotesICDLabsMIMIC4`` that uses only: - - - **ICD codes**: diagnosis and procedure codes per admission, processed by - ``StageNetProcessor`` with inter-admission time offsets. - - **Lab values**: 10-dimensional lab vectors (one per lab category) at each - measurement timestamp, processed by ``StageNetTensorProcessor``. - - Examples: - >>> from pyhealth.datasets import MIMIC4Dataset - >>> from pyhealth.tasks.multimodal_mimic4 import ICDLabsMIMIC4 - >>> dataset = MIMIC4Dataset( - ... ehr_root="/path/to/mimic-iv/2.2", - ... ehr_tables=["diagnoses_icd", "procedures_icd", "labevents"], - ... ) - >>> task = ICDLabsMIMIC4() - >>> samples = dataset.set_task(task) - """ - - PADDING: int = 0 - - task_name: str = "ICDLabsMIMIC4" - input_schema: Dict[str, Union[str, Tuple[str, Dict]]] = { - "icd_codes": ("stagenet", {"padding": PADDING}), - "labs": ("stagenet_tensor", {}), - "labs_mask": ("stagenet_tensor", {}), - } - output_schema: Dict[str, str] = {"mortality": "binary"} - - def __call__(self, patient: Any) -> List[Dict[str, Any]]: - demographics = patient.get_events(event_type="patients") - if not demographics: - return [] - - admissions_to_process, mortality_label = self._build_admissions_to_process( - patient - ) - - if len(admissions_to_process) == 0: - return [] - - effective_start, effective_end = self._compute_effective_window( - admissions_to_process - ) - - all_icd_codes: List[List[str]] = [] - all_icd_times: List[float] = [] - all_lab_values: List[List[float]] = [] - all_lab_masks: List[List[bool]] = [] - all_lab_times: List[float] = [] - previous_admission_time = None - - for admission in admissions_to_process: - admission_time = admission.timestamp - - try: - admission_dischtime = datetime.strptime( - admission.dischtime, "%Y-%m-%d %H:%M:%S" - ) - except (ValueError, AttributeError): - continue - - if admission_dischtime < admission_time: - continue - - visit_icd_codes = self._collect_icd_codes(patient, admission.hadm_id) - if visit_icd_codes: - if previous_admission_time is None: - time_from_previous = 0.0 - else: - time_from_previous = self._to_hours( - (admission_time - previous_admission_time).total_seconds() - ) - all_icd_codes.append(visit_icd_codes) - all_icd_times.append(time_from_previous) - else: - all_icd_codes.append([self.MISSING_CODE_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) - - previous_admission_time = admission_time - - lab_times, lab_values, lab_masks = self._collect_labs( - patient=patient, - admission_time=admission_time, - end_time=admission_dischtime, - ) - all_lab_times.extend(lab_times) - all_lab_values.extend(lab_values) - all_lab_masks.extend(lab_masks) - - if len(all_lab_values) == 0: - all_lab_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.LAB_CATEGORY_NAMES) - ) - all_lab_masks.append([False] * len(self.LAB_CATEGORY_NAMES)) - all_lab_times.append(self.MISSING_FLOAT_TOKEN) - - if len(all_icd_codes) == 0: - all_icd_codes.append([self.MISSING_CODE_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) - - single_patient_longitudinal_record = { - "patient_id": patient.patient_id, - "icd_codes": (all_icd_times, all_icd_codes), - "labs": (all_lab_times, all_lab_values), - "labs_mask": (all_lab_times, all_lab_masks), - "mortality": mortality_label, - "window_start": effective_start, - "window_end": effective_end, - } - - return [single_patient_longitudinal_record] - +# NOTE: a second, identical-named ICDLabsMIMIC4 previously appeared here and +# was shadowed by the definition below, which is the one Python binds and +# therefore the one that produced every published number. The dead copy +# differed (MISSING_CODE_TOKEN, and it skipped admissions with no dischtime), +# so leaving it invited a silent behaviour swap on any future edit. class ClinicalNotesICDLabsCXRMIMIC4(BaseMultimodalMIMIC4Task): """Task combining notes, ICD, labs, and CXR for MIMIC-IV mortality. @@ -1043,16 +901,15 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: ) all_icd_codes.append(visit_icd_codes) all_icd_times.append(time_from_previous) - else: - all_icd_codes.append([self.MISSING_CODE_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) previous_admission_time = admission_time lab_times, lab_values, lab_masks = self._collect_labs( patient=patient, admission_time=admission_time, - end_time=admission_dischtime, + end_time=self._admission_window_end( + admission_time, admission_dischtime + ), ) all_lab_times.extend(lab_times) all_lab_values.extend(lab_values) @@ -1076,24 +933,6 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: except AttributeError: continue - if len(all_lab_values) == 0: - all_lab_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.LAB_CATEGORY_NAMES) - ) - all_lab_masks.append([False] * len(self.LAB_CATEGORY_NAMES)) - all_lab_times.append(self.MISSING_FLOAT_TOKEN) - - # If all admissions were skipped before ICD collection, ensure a - # single placeholder step so StageNetProcessor does not emit None time. - if len(all_icd_codes) == 0: - all_icd_codes.append([self.MISSING_CODE_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) - - # time_image processor expects at least one path/time pair. - if len(all_cxr_paths) == 0: - all_cxr_paths = [self.MISSING_TEXT_TOKEN] - all_cxr_times = [self.MISSING_FLOAT_TOKEN] - discharge_note_times_from_admission = ( all_discharge_texts, all_discharge_times_from_admission, @@ -1103,20 +942,6 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: all_radiology_times_from_admission, ) - # Per-admission note fallback happens inside _collect_notes(). - # This final guard handles the edge case where every admission was - # skipped before _collect_notes() was reached. - if len(all_discharge_texts) == 0: - discharge_note_times_from_admission = ( - [self.MISSING_TEXT_TOKEN], - [self.MISSING_FLOAT_TOKEN], - ) - if len(all_radiology_texts) == 0: - radiology_note_times_from_admission = ( - [self.MISSING_TEXT_TOKEN], - [self.MISSING_FLOAT_TOKEN], - ) - single_patient_longitudinal_record = { "patient_id": patient.patient_id, "discharge_note_times": discharge_note_times_from_admission, @@ -1209,32 +1034,20 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: ) all_icd_codes.append(visit_icd_codes) all_icd_times.append(time_from_previous) - else: - all_icd_codes.append([self.MISSING_TEXT_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) previous_admission_time = admission_time lab_times, lab_values, lab_masks = self._collect_labs( patient=patient, admission_time=admission_time, - end_time=admission_dischtime, + end_time=self._admission_window_end( + admission_time, admission_dischtime + ), ) all_lab_times.extend(lab_times) all_lab_values.extend(lab_values) all_lab_masks.extend(lab_masks) - if len(all_lab_values) == 0: - all_lab_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.LAB_CATEGORY_NAMES) - ) - all_lab_masks.append([False] * len(self.LAB_CATEGORY_NAMES)) - all_lab_times.append(self.MISSING_FLOAT_TOKEN) - - if len(all_icd_codes) == 0: - all_icd_codes.append([self.MISSING_TEXT_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) - single_patient_longitudinal_record = { "patient_id": patient.patient_id, "icd_codes": (all_icd_times, all_icd_codes), @@ -1365,12 +1178,8 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: all_note_texts.extend(note_texts) all_note_times.extend(note_times) - # Labs within the observation window - lab_end = ( - effective_end - if self.window_hours is not None - else admission_dischtime - ) + # Labs within the observation window of THIS admission. + lab_end = self._admission_window_end(admission_time, admission_dischtime) lab_times, lab_values, lab_masks = self._collect_labs( patient=patient, admission_time=admission_time, @@ -1403,29 +1212,8 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: if visit_icd_codes: all_icd_codes.append(visit_icd_codes) all_icd_times.append(time_from_previous) - else: - all_icd_codes.append([self.MISSING_TEXT_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) previous_admission_time = admission_time - if not all_lab_values: - all_lab_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.LAB_CATEGORY_NAMES) - ) - all_lab_masks.append([False] * len(self.LAB_CATEGORY_NAMES)) - all_lab_times.append(self.MISSING_FLOAT_TOKEN) - - if self.include_vitals and not all_vital_values: - all_vital_values.append( - [self.MISSING_FLOAT_TOKEN] * len(self.VITAL_CATEGORY_NAMES) - ) - all_vital_masks.append([False] * len(self.VITAL_CATEGORY_NAMES)) - all_vital_times.append(self.MISSING_FLOAT_TOKEN) - - if not all_note_texts: - all_note_texts = [self.MISSING_TEXT_TOKEN] - all_note_times = [self.MISSING_FLOAT_TOKEN] - record: Dict[str, Any] = { "patient_id": patient.patient_id, "admission_note_times": (all_note_texts, all_note_times), @@ -1441,9 +1229,91 @@ def __call__(self, patient: Any) -> List[Dict[str, Any]]: record["vitals_mask"] = (all_vital_times, all_vital_masks) if self.include_icd: - if not all_icd_codes: - all_icd_codes.append([self.MISSING_TEXT_TOKEN]) - all_icd_times.append(self.MISSING_FLOAT_TOKEN) record["icd_codes"] = (all_icd_times, all_icd_codes) return [record] + + +class LabsOnlyMIMIC4(BaseMultimodalMIMIC4Task): + """EHR-only mortality prediction using lab values — no notes, no ICD codes. + + Serves as the structured-EHR reference baseline for multimodal ablations. + Collecting only ``labevents`` keeps the dataset loader fast and avoids any + leakage from discharge-coded ICD tables. + + Schema mirrors the ``labs`` / ``labs_mask`` fields from ``NotesLabsMIMIC4`` + so the same backbone models (MLP, RNN, Transformer, …) work unchanged. + + Args: + window_hours: Hours from admission to collect lab measurements. + ``None`` collects for the full admission span. Default: 24. + + Example:: + + >>> from pyhealth.datasets import MIMIC4Dataset + >>> from pyhealth.tasks.multimodal_mimic4 import LabsOnlyMIMIC4 + >>> dataset = MIMIC4Dataset( + ... ehr_root="/data/mimiciv/2.2", + ... ehr_tables=["labevents"], + ... ) + >>> task = LabsOnlyMIMIC4(window_hours=24) + >>> samples = dataset.set_task(task) + """ + + task_name: str = "LabsOnlyMIMIC4" + input_schema: Dict[str, Union[str, Tuple[str, Dict]]] = { + "labs": ("stagenet_tensor", {}), + "labs_mask": ("stagenet_tensor", {}), + } + output_schema: Dict[str, str] = {"mortality": "binary"} + + + def __call__(self, patient: Any) -> List[Dict[str, Any]]: + demographics = patient.get_events(event_type="patients") + if not demographics: + return [] + + admissions_to_process, mortality_label = self._build_admissions_to_process(patient) + if not admissions_to_process: + return [] + + effective_start, effective_end = self._compute_effective_window(admissions_to_process) + + all_lab_times: List[float] = [] + all_lab_values: List[List[float]] = [] + all_lab_masks: List[List[bool]] = [] + + for admission in admissions_to_process: + admission_time = admission.timestamp + try: + admission_dischtime = datetime.strptime(admission.dischtime, "%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + admission_dischtime = admission_time + if admission_dischtime < admission_time: + admission_dischtime = admission_time + + # Honour the observation window. Collecting through dischtime leaks: + # labs drawn hours before death are near-deterministic for a mortality + # label, so the baseline was reading the answer. NotesLabsMIMIC4 bounds + # labs the same way, and the two must agree or a modality ablation + # compares a leaky arm against an honest one. + lab_end = self._admission_window_end( + admission_time, admission_dischtime + ) + lab_times, lab_values, lab_masks = self._collect_labs( + patient=patient, + admission_time=admission_time, + end_time=lab_end, + ) + all_lab_times.extend(lab_times) + all_lab_values.extend(lab_values) + all_lab_masks.extend(lab_masks) + + return [{ + "patient_id": patient.patient_id, + "labs": (all_lab_times, all_lab_values), + "labs_mask": (all_lab_times, all_lab_masks), + "mortality": mortality_label, + "window_start": effective_start, + "window_end": effective_end, + }] diff --git a/tests/core/test_notes_labs_mimic4.py b/tests/core/test_notes_labs_mimic4.py index 4f6c5cb6b..664c370ea 100644 --- a/tests/core/test_notes_labs_mimic4.py +++ b/tests/core/test_notes_labs_mimic4.py @@ -156,44 +156,37 @@ def test_empty_string_fallback(self): class TestCollectAdmissionNoteSections(unittest.TestCase): - def test_collects_sections_and_returns_time_zero(self): + def test_collects_sections(self): from pyhealth.tasks.multimodal_mimic4 import NotesLabsMIMIC4 task = NotesLabsMIMIC4() patient = _DummyPatientWithNotes( note_texts=["Chief Complaint:\nFever\n\nPast Medical History:\nDiabetes"] ) - texts, times = task._collect_admission_note_sections( - patient, 101, datetime(2020, 1, 1, 0, 0, 0) + texts, times = task._collect_notes( + patient, + "discharge", + 101, + datetime(2020, 1, 1, 0, 0, 0), + section_headers=task.DISCHARGE_CLINICAL_HEADERS, ) - self.assertEqual(len(texts), 1) - self.assertIn("Fever", texts[0]) - self.assertEqual(times, [0.0]) + self.assertGreaterEqual(len(texts), 1) + self.assertTrue(any("Fever" in t or "Diabetes" in t for t in texts)) - def test_missing_note_fallback(self): + def test_missing_note_is_empty_not_a_placeholder(self): from pyhealth.tasks.multimodal_mimic4 import NotesLabsMIMIC4 task = NotesLabsMIMIC4() patient = _DummyPatientWithNotes(note_texts=[]) - texts, times = task._collect_admission_note_sections( - patient, 101, datetime(2020, 1, 1, 0, 0, 0) + texts, times = task._collect_notes( + patient, + "discharge", + 101, + datetime(2020, 1, 1, 0, 0, 0), + section_headers=task.DISCHARGE_CLINICAL_HEADERS, ) - self.assertEqual(texts, [""]) - self.assertEqual(times, [0.0]) - - def test_no_time_filter_applied(self): - from pyhealth.tasks.multimodal_mimic4 import NotesLabsMIMIC4 - - task = NotesLabsMIMIC4() - # Discharge note timestamp is 2020-01-03, well outside any 24h window - patient = _DummyPatientWithNotes( - note_texts=["Chief Complaint:\nFever"] - ) - texts, times = task._collect_admission_note_sections( - patient, 101, datetime(2020, 1, 1, 0, 0, 0) - ) - self.assertEqual(len(texts), 1) - self.assertIn("Fever", texts[0]) + self.assertEqual(texts, []) + self.assertEqual(times, []) class TestNotesLabsMIMIC4(unittest.TestCase): @@ -294,8 +287,8 @@ def test_vitals_fallback_when_empty(self): samples = task(patient) self.assertEqual(len(samples), 1) vital_times, vital_values = samples[0]["vitals"] - self.assertEqual(len(vital_times), 1) - self.assertEqual(len(vital_values[0]), len(task.VITAL_CATEGORY_NAMES)) + self.assertEqual(len(vital_times), 0) + self.assertEqual(len(vital_values), 0) def test_output_structure_no_icd(self): import polars as pl @@ -377,11 +370,13 @@ def test_malformed_dischtime_does_not_drop_admission(self): samples = task(patient) self.assertEqual(len(samples), 1) sample = samples[0] - self.assertGreater(len(sample["icd_codes"][0]), 0) - self.assertGreater(len(sample["labs"][0]), 0) - self.assertGreater(len(sample["labs_mask"][0]), 0) + # Malformed dischtime used to skip the admission entirely. The sample + # is still emitted; empty ICD/labs are real absence, not a reason to drop. + self.assertEqual(sample["patient_id"], "p-2") + self.assertIn("icd_codes", sample) + self.assertIn("labs", sample) - def test_missing_icd_code_uses_text_token(self): + def test_missing_icd_code_is_zero_visits_not_a_fake_token(self): import polars as pl from pyhealth.tasks.multimodal_mimic4 import ICDLabsMIMIC4 @@ -401,8 +396,9 @@ def test_missing_icd_code_uses_text_token(self): ) samples = task(patient) self.assertEqual(len(samples), 1) - _, icd_visits = samples[0]["icd_codes"] - self.assertEqual(icd_visits, [[""]]) + icd_times, icd_visits = samples[0]["icd_codes"] + self.assertEqual(icd_visits, []) + self.assertEqual(icd_times, []) if __name__ == "__main__": diff --git a/tests/core/test_stagenet_processor.py b/tests/core/test_stagenet_processor.py index 6e217dc7d..1cde77f6a 100644 --- a/tests/core/test_stagenet_processor.py +++ b/tests/core/test_stagenet_processor.py @@ -199,9 +199,8 @@ def test_empty_codes_flat(self): time, values = processor.process((None, [])) - # Should return single padding token - self.assertEqual(values.shape, (1,)) - self.assertEqual(values[0].item(), processor.code_vocab[""]) + # Should return zero events, not a fake pad token + self.assertEqual(values.shape, (0,)) def test_empty_codes_nested(self): """Test processing empty nested codes.""" @@ -211,10 +210,8 @@ def test_empty_codes_nested(self): time, values = processor.process((None, [])) - # Should return single row of padding tokens - self.assertEqual(values.shape, (1, 2)) - self.assertEqual(values[0, 0].item(), processor.code_vocab[""]) - self.assertEqual(values[0, 1].item(), processor.code_vocab[""]) + # Should return zero events, not a fake pad row + self.assertEqual(values.shape, (0, 2)) def test_vocab_size_method(self): """Test vocab_size() returns correct size.""" diff --git a/tests/core/test_time_image_processor.py b/tests/core/test_time_image_processor.py index b1eb46cc2..fc260823a 100644 --- a/tests/core/test_time_image_processor.py +++ b/tests/core/test_time_image_processor.py @@ -207,11 +207,13 @@ def test_process_mismatched_lengths_raises(self): (self.rgb_paths[:3], self.times[:2]) ) - def test_process_empty_paths_raises(self): - """ValueError for empty image list.""" - proc = TimeImageProcessor() - with self.assertRaises(ValueError): - proc.process(([], [])) + def test_process_empty_paths_is_zero_events(self): + """Empty image list is zero events, not a black placeholder frame.""" + proc = TimeImageProcessor(image_size=32, mode="L") + images, times, tag = proc.process(([], [])) + self.assertEqual(tuple(images.shape), (0, 1, 32, 32)) + self.assertEqual(tuple(times.shape), (0,)) + self.assertEqual(tag, "image") def test_process_invalid_path_raises(self): """FileNotFoundError for nonexistent image.""" diff --git a/tests/test_no_missing_placeholder.py b/tests/test_no_missing_placeholder.py new file mode 100644 index 000000000..6106ea206 --- /dev/null +++ b/tests/test_no_missing_placeholder.py @@ -0,0 +1,117 @@ +"""Proof that empty notes/labs/CXR/ICD are zero events, not fake rows. + +The previous fallback stuffed ``[MISSING_TEXT]`` / empty-string notes / a pad +ICD visit / a black image / a zero lab row so the fast tokenizer would not +crash. BERT then embedded a constant, and note presence tracked mortality. +""" + +from __future__ import annotations + +import inspect +import unittest +from datetime import datetime + + +class TestProcessorsEmitZeroEvents(unittest.TestCase): + def test_empty_note_list_is_not_missing_text(self): + from pyhealth.processors.tuple_time_text_processor import TupleTimeTextProcessor + + texts, times, tag = TupleTimeTextProcessor().process(([], [])) + self.assertEqual(texts, []) + self.assertEqual(tuple(times.shape), (0,)) + self.assertEqual(tag, "note") + + def test_empty_codes_are_not_a_pad_visit(self): + from pyhealth.processors.stagenet_processor import StageNetProcessor + + proc = StageNetProcessor() + proc.fit([{"data": ([0.0], ["A"])}], "data") + _, values = proc.process((None, [])) + self.assertEqual(tuple(values.shape), (0,)) + + def test_empty_images_are_not_a_black_frame(self): + from pyhealth.processors.time_image_processor import TimeImageProcessor + + images, times, tag = TimeImageProcessor(image_size=16, mode="L").process( + ([], []) + ) + self.assertEqual(tuple(images.shape), (0, 1, 16, 16)) + self.assertEqual(tuple(times.shape), (0,)) + self.assertEqual(tag, "image") + + +class TestTasksDoNotInjectPlaceholders(unittest.TestCase): + def test_task_bodies_do_not_stuff_missing_text(self): + from pyhealth.tasks import multimodal_mimic4 as m + + for name in ( + "ClinicalNotesMIMIC4", + "ClinicalNotesICDLabsMIMIC4", + "ClinicalNotesICDLabsCXRMIMIC4", + "ICDLabsMIMIC4", + "NotesLabsMIMIC4", + "LabsOnlyMIMIC4", + ): + src = inspect.getsource(getattr(m, name).__call__) + self.assertNotIn( + "MISSING_TEXT_TOKEN", + src, + msg=f"{name} still injects a fake missing-text event", + ) + self.assertNotIn( + "MISSING_CODE_TOKEN", + src, + msg=f"{name} still injects a fake missing-code visit", + ) + + def test_notes_labs_empty_patient_emits_empty_lists(self): + import polars as pl + from pyhealth.tasks.multimodal_mimic4 import NotesLabsMIMIC4 + + class _Event: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + class _Patient: + patient_id = "p-1" + + def get_events( + self, event_type, start=None, end=None, filters=None, return_df=False + ): + if event_type == "patients": + return [_Event(anchor_age=55)] + if event_type == "admissions": + return [ + _Event( + timestamp=datetime(2020, 1, 1, 0, 0, 0), + dischtime="2020-01-03 12:00:00", + hadm_id=101, + hospital_expire_flag=0, + ) + ] + if return_df: + return pl.DataFrame( + { + "timestamp": [], + "labevents/itemid": [], + "labevents/storetime": [], + "labevents/valuenum": [], + } + ) + return [] + + samples = NotesLabsMIMIC4(window_hours=24)(_Patient()) + self.assertEqual(len(samples), 1) + notes, note_times = samples[0]["admission_note_times"] + lab_times, lab_values = samples[0]["labs"] + self.assertEqual(notes, []) + self.assertEqual(note_times, []) + self.assertEqual(lab_times, []) + self.assertEqual(lab_values, []) + + def test_notes_labs_call_uses_per_admission_window(self): + from pyhealth.tasks.multimodal_mimic4 import NotesLabsMIMIC4 + + src = inspect.getsource(NotesLabsMIMIC4.__call__) + self.assertIn("_admission_window_end", src) diff --git a/tests/test_observation_window.py b/tests/test_observation_window.py new file mode 100644 index 000000000..f1ed3a948 --- /dev/null +++ b/tests/test_observation_window.py @@ -0,0 +1,121 @@ +"""Regression tests for observation-window correctness. + +Each targets a defect that reached real results, and each is written to fail +against the pre-fix code rather than to restate the implementation. +""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timedelta +from types import SimpleNamespace + +import pytest + + +LAB_TASKS = [ + "LabsOnlyMIMIC4", + "ICDLabsMIMIC4", + "ClinicalNotesICDLabsMIMIC4", + "ClinicalNotesICDLabsCXRMIMIC4", + "NotesLabsMIMIC4", +] + + +@pytest.mark.parametrize("task_name", LAB_TASKS) +def test_every_lab_task_honours_its_observation_window(task_name): + """Collecting labs through DISCHARGE under a declared 24h window leaks. + + Labs drawn hours before death are near-deterministic for a mortality label. + All four task bodies computed an observation window and then passed + ``admission_dischtime`` anyway, so ``window_hours`` was inert. Parameterised + over every lab-emitting task rather than pinned to the one found first. + """ + from pyhealth.tasks import multimodal_mimic4 as m + + task = getattr(m, task_name)(window_hours=24) + admit = datetime(2180, 5, 6, 8, 0, 0) + discharge = admit + timedelta(days=9) + + end = task._admission_window_end(admit, discharge) + horizon = (end - admit).total_seconds() / 3600.0 + + assert horizon == pytest.approx(24.0, abs=0.01), ( + f"{task_name} collects labs {horizon:.0f}h past admission against a " + f"declared 24h window; anything beyond it leaks the outcome" + ) + assert end < discharge + + +@pytest.mark.parametrize("task_name", LAB_TASKS) +def test_observation_window_is_anchored_per_admission(task_name): + """The window anchored on the FIRST admission globally. + + Later admissions then received a span ending before it began, collected + nothing, and the task injected a placeholder row for each, so sequence + length encoded the patient's future admission count. + """ + from pyhealth.tasks import multimodal_mimic4 as m + + task = getattr(m, task_name)(window_hours=24) + first = datetime(2180, 5, 6, 8, 0, 0) + later = first + timedelta(days=400) + + # A long stay is bounded by the window. + assert task._admission_window_end(first, first + timedelta(days=9)) == \ + first + timedelta(hours=24) + # A stay shorter than the window is bounded by discharge, not the window. + assert task._admission_window_end(first, first + timedelta(hours=6)) == \ + first + timedelta(hours=6) + # A later admission gets its OWN window, not one anchored 400 days earlier. + end = task._admission_window_end(later, later + timedelta(days=5)) + assert end == later + timedelta(hours=24) + assert end > later, "later admission received an already-expired window" + + +@pytest.mark.parametrize("task_name", LAB_TASKS) +def test_window_change_invalidates_the_cache(task_name): + """A code fix alone leaves existing caches serving superseded samples. + + The task cache key is uuid5 over ``{**vars(task), schemas}``, so without a + version marker every previously built cache is silently reused. + """ + from pyhealth.tasks import multimodal_mimic4 as m + + task = getattr(m, task_name)(window_hours=24) + assert vars(task).get("emitted_data_version") is not None, ( + f"{task_name} emits different data after the window fix but carries no " + f"version marker, so stale leaky samples would be reused silently" + ) + + def cache_key(t, drop_version=False): + v = dict(vars(t)) + if drop_version: + v.pop("emitted_data_version", None) + params = json.dumps( + {**v, "input_schema": t.input_schema, "output_schema": t.output_schema}, + sort_keys=True, default=str, + ) + return str(uuid.uuid5(uuid.NAMESPACE_DNS, params)) + + assert cache_key(task) != cache_key(task, drop_version=True) + + +def test_window_none_still_collects_through_discharge(): + """window_hours=None is the explicit whole-stay mode and must be preserved.""" + from pyhealth.tasks.multimodal_mimic4 import LabsOnlyMIMIC4 + + task = LabsOnlyMIMIC4(window_hours=None) + admit = datetime(2180, 5, 6, 8, 0, 0) + discharge = admit + timedelta(days=9) + assert task._admission_window_end(admit, discharge) == discharge + + +def test_icd_labs_task_is_defined_once(): + """A shadowed duplicate silently swaps behaviour on any future edit.""" + import inspect + from pyhealth.tasks import multimodal_mimic4 as m + + source = inspect.getsource(m) + assert source.count("\nclass ICDLabsMIMIC4(") == 1 diff --git a/tests/test_tuple_time_text_processor.py b/tests/test_tuple_time_text_processor.py index b5fbd48e3..6d74cf009 100644 --- a/tests/test_tuple_time_text_processor.py +++ b/tests/test_tuple_time_text_processor.py @@ -25,22 +25,27 @@ def test_tuple_time_text_processor(): assert torch.equal(time_tensor, torch.tensor([0.0, 24.0, 72.0])) assert tag == "clinical_note" + # Empty input is zero events, not a fake "[MISSING_TEXT]" token. + empty_texts, empty_time, empty_tag = processor.process(([], [])) + assert empty_texts == [] + assert empty_time.shape == (0,) + assert empty_tag == "clinical_note" + # Test registration from pyhealth.processors import get_processor ProcessorClass = get_processor("tuple_time_text") assert ProcessorClass is TupleTimeTextProcessor -def test_tuple_time_text_processor_empty_input_fallback(): - """Empty or whitespace-only text lists should not crash processing.""" +def test_tuple_time_text_processor_empty_input_is_zero_events(): + """Whitespace-only notes are dropped, not replaced with a fake token.""" processor = TupleTimeTextProcessor(type_tag="clinical_note") texts = [" ", None, ""] time_diffs = [1.0, 2.0, 3.0] result_texts, time_tensor, tag = processor.process((texts, time_diffs)) - assert result_texts == ["[MISSING_TEXT]"] + assert result_texts == [] assert isinstance(time_tensor, torch.Tensor) - assert time_tensor.shape == (1,) - assert torch.equal(time_tensor, torch.tensor([0.0])) + assert time_tensor.shape == (0,) assert tag == "clinical_note" diff --git a/tests/test_tuple_time_text_tokenizer.py b/tests/test_tuple_time_text_tokenizer.py index 1024e2a70..137f7a98c 100644 --- a/tests/test_tuple_time_text_tokenizer.py +++ b/tests/test_tuple_time_text_tokenizer.py @@ -122,23 +122,21 @@ def test_tokenizer_integration_in_pyhealth_workflow(): @pytest.mark.skipif(not TRANSFORMERS_AVAILABLE, reason="Transformers not installed") -def test_tuple_time_text_processor_with_tokenizer_empty_input_fallback(): - """Tokenizer mode should handle empty/malformed note batches gracefully.""" +def test_tuple_time_text_processor_with_tokenizer_empty_input_is_zero_events(): + """Tokenizer mode emits a (0, 1) tensor, not a one-row [MISSING_TEXT] encoding.""" processor = TupleTimeTextProcessor( tokenizer_model="prajjwal1/bert-tiny", max_length=8, ) - # Empty texts and invalid timestamps should fallback to one missing token. input_ids, attention_mask, token_type_ids, time_tensor, tag = processor.process( (["", " ", None], ["bad", None, "nan"]) ) - assert input_ids.shape == (1, 8) - assert attention_mask.shape == (1, 8) - assert token_type_ids.shape == (1, 8) - assert time_tensor.shape == (1,) - assert torch.equal(time_tensor, torch.tensor([0.0])) + assert input_ids.shape == (0, 1) + assert attention_mask.shape == (0, 1) + assert token_type_ids.shape == (0, 1) + assert time_tensor.shape == (0,) assert tag == "note"