Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyhealth/datasets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
118 changes: 118 additions & 0 deletions pyhealth/datasets/splitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 4 additions & 5 deletions pyhealth/processors/stagenet_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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["<pad>"]], dtype=torch.long)
return torch.zeros((0,), dtype=torch.long)

indices = []
for code in codes:
Expand All @@ -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["<pad>"]
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
Expand Down
15 changes: 13 additions & 2 deletions pyhealth/processors/time_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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])

Expand Down
15 changes: 7 additions & 8 deletions pyhealth/processors/tuple_time_text_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyhealth/tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
)
from .multimodal_mimic4 import (
ClinicalNotesMIMIC4,
ClinicalNotesICDLabsMIMIC4,
NotesLabsMIMIC4,
)
from .patient_linkage import patient_linkage_mimic3_fn
Expand All @@ -73,6 +72,7 @@
from .multimodal_mimic4 import (
ClinicalNotesMIMIC4,
ClinicalNotesICDLabsMIMIC4,
LabsOnlyMIMIC4,
ClinicalNotesICDLabsCXRMIMIC4,
)
from .patient_linkage_mimic3 import PatientLinkageMIMIC3Task
Loading
Loading