# targets.py from __future__ import annotations from dataclasses import dataclass from typing import Iterable import numpy as np PAD_IDX = 0 CHECKUP_IDX = 1 NO_EVENT_IDX = 2 DAYS_PER_YEAR = 365.25 @dataclass(frozen=True) class NextTokenTargets: """ Delphi2M-style next-token supervision targets. Shapes: input_events: (L,) input_times_years: (L,) target_events: (L,) target_times_years:(L,) where L = N - 1. """ input_events: np.ndarray input_times_years: np.ndarray target_events: np.ndarray target_times_years: np.ndarray @dataclass(frozen=True) class UniqueTimeSetTargets: """ Unique-time set supervision targets. Shapes: readout_mask: (L,) target_dt_unique: (L,) target_multi_hot: (L, vocab_size) where L = N - 1. Only group-end positions can have readout_mask=True. target_dt_unique is measured in years. """ readout_mask: np.ndarray target_dt_unique: np.ndarray target_multi_hot: np.ndarray @dataclass(frozen=True) class TargetPack: """ Combined target package for one patient sequence. Contains both next-token targets and unique-time-set targets. The training pipeline decides which one to use. """ next_token: NextTokenTargets unique_time_set: UniqueTimeSetTargets def _as_numpy_1d( x: np.ndarray, name: str, dtype: np.dtype | type | None = None, ) -> np.ndarray: arr = np.asarray(x) if arr.ndim != 1: raise ValueError(f"{name} must be 1D, got shape {arr.shape}") if dtype is not None: arr = arr.astype(dtype) return arr def validate_event_sequence( labels: np.ndarray, times_days: np.ndarray, *, require_sorted: bool = True, ) -> None: """ Validate one patient's event sequence. labels: 1D integer label ids. times_days: 1D event times in days. require_sorted: If True, raises when times_days is not non-decreasing. """ labels = _as_numpy_1d(labels, "labels") times_days = _as_numpy_1d(times_days, "times_days") if len(labels) != len(times_days): raise ValueError( f"labels and times_days must have the same length, " f"got {len(labels)} and {len(times_days)}" ) if len(labels) == 0: raise ValueError("Empty event sequence is not valid.") if np.any(labels < 0): raise ValueError("labels contains negative ids.") if not np.all(np.isfinite(times_days)): raise ValueError("times_days contains non-finite values.") if require_sorted and len(times_days) > 1: if np.any(np.diff(times_days) < 0): raise ValueError("times_days must be non-decreasing.") def build_next_token_targets( labels: np.ndarray, times_days: np.ndarray, *, require_sorted: bool = True, ) -> NextTokenTargets: """ Build Delphi2M-style autoregressive next-token targets. Given full sequence: labels: [x0, x1, x2, ..., xN-1] times_days: [t0, t1, t2, ..., tN-1] returns: input_events: [x0, x1, ..., xN-2] input_times_years: [t0, t1, ..., tN-2] / 365.25 target_events: [x1, x2, ..., xN-1] target_times_years: [t1, t2, ..., tN-1] / 365.25 This function does not ignore PAD/CHECKUP/NO_EVENT. Ignoring belongs to the loss function because different objectives may use different ignore ids. """ labels = _as_numpy_1d(labels, "labels", np.int64) times_days = _as_numpy_1d(times_days, "times_days", np.float32) validate_event_sequence(labels, times_days, require_sorted=require_sorted) if len(labels) < 2: raise ValueError( "Need at least two events to build next-token targets." ) input_events = labels[:-1].astype(np.int64) input_times_years = (times_days[:-1] / DAYS_PER_YEAR).astype(np.float32) target_events = labels[1:].astype(np.int64) target_times_years = (times_days[1:] / DAYS_PER_YEAR).astype(np.float32) return NextTokenTargets( input_events=input_events, input_times_years=input_times_years, target_events=target_events, target_times_years=target_times_years, ) def build_unique_time_set_targets( labels: np.ndarray, times_days: np.ndarray, *, vocab_size: int, ignored_target_ids: Iterable[int] = (PAD_IDX, CHECKUP_IDX), require_sorted: bool = True, ) -> UniqueTimeSetTargets: """ Build next-unique-time set targets. This is the target construction used by your UTS / default mode. For each input position i: - only if i is the last token of its timestamp group; - find the next distinct timestamp group; - target is the set of valid event labels at that next timestamp. Example: t=49: X t=50: A, B, C t=51: D, E Supervises: X@49 -> {A, B, C}@50 group_end@50 -> {D, E}@51 It does NOT supervise: A@50 -> B@50 B@50 -> C@50 Parameters ---------- labels: Full event sequence labels, shape (N,). times_days: Full event sequence times in days, shape (N,). vocab_size: Size of output vocabulary. ignored_target_ids: Label ids that should not enter target_multi_hot. Usually: no no-event: {0, 1} with no-event: {0, 1, 2} For UTS, I recommend ignoring unless explicitly testing it as an event target. Returns ------- UniqueTimeSetTargets """ labels = _as_numpy_1d(labels, "labels", np.int64) times_days = _as_numpy_1d(times_days, "times_days", np.float32) validate_event_sequence(labels, times_days, require_sorted=require_sorted) if vocab_size <= 0: raise ValueError(f"vocab_size must be positive, got {vocab_size}") if len(labels) < 2: raise ValueError( "Need at least two events to build unique-time-set targets." ) input_len = len(labels) - 1 readout_mask = np.zeros(input_len, dtype=bool) target_dt_unique = np.zeros(input_len, dtype=np.float32) target_multi_hot = np.zeros((input_len, vocab_size), dtype=bool) ignored = {int(x) for x in ignored_target_ids} unique_times = np.unique(times_days) time_to_group_idx = {t: i for i, t in enumerate(unique_times)} group_indices = np.array([time_to_group_idx[t] for t in times_days], dtype=np.int64) for i in range(input_len): current_group = group_indices[i] is_last_in_group = ( i == input_len - 1 or group_indices[i + 1] != current_group ) if not is_last_in_group: continue next_group_idx = current_group + 1 if next_group_idx >= len(unique_times): continue next_time = unique_times[next_group_idx] next_labels = labels[group_indices == next_group_idx] valid_next_labels: list[int] = [] for lab in next_labels: lab_int = int(lab) if lab_int in ignored: continue if lab_int < 0 or lab_int >= vocab_size: continue valid_next_labels.append(lab_int) # If next timestamp contains only technical tokens, do not supervise UTS. if len(valid_next_labels) == 0: continue readout_mask[i] = True target_dt_unique[i] = float(next_time - times_days[i]) / DAYS_PER_YEAR target_multi_hot[i, valid_next_labels] = True return UniqueTimeSetTargets( readout_mask=readout_mask, target_dt_unique=target_dt_unique.astype(np.float32), target_multi_hot=target_multi_hot, ) def build_all_targets( labels: np.ndarray, times_days: np.ndarray, *, vocab_size: int, ignored_uts_target_ids: Iterable[int] = (PAD_IDX, CHECKUP_IDX), require_sorted: bool = True, ) -> TargetPack: """ Build both next-token targets and unique-time-set targets for one patient. This is the function dataset.py should usually call during initialization. The dataset can then store: event_seq = target_pack.next_token.input_events time_seq = target_pack.next_token.input_times_years target_event_seq = target_pack.next_token.target_events target_time_seq = target_pack.next_token.target_times_years readout_mask = target_pack.unique_time_set.readout_mask target_dt_unique = target_pack.unique_time_set.target_dt_unique target_multi_hot = target_pack.unique_time_set.target_multi_hot """ next_token = build_next_token_targets( labels=labels, times_days=times_days, require_sorted=require_sorted, ) unique_time_set = build_unique_time_set_targets( labels=labels, times_days=times_days, vocab_size=vocab_size, ignored_target_ids=ignored_uts_target_ids, require_sorted=require_sorted, ) return TargetPack( next_token=next_token, unique_time_set=unique_time_set, ) def get_group_end_mask_from_times( times_days: np.ndarray, *, input_len: int | None = None, ) -> np.ndarray: """ Convenience utility for debugging. Returns a bool mask indicating the last token of each same-time group within the input sequence. If input_len is None, uses len(times_days) - 1, matching model input length. """ times_days = _as_numpy_1d(times_days, "times_days", np.float32) if input_len is None: input_len = len(times_days) - 1 if input_len < 0 or input_len > len(times_days): raise ValueError( f"Invalid input_len={input_len} for sequence length {len(times_days)}" ) out = np.zeros(input_len, dtype=bool) for i in range(input_len): is_last_in_group = ( i == input_len - 1 or times_days[i + 1] != times_days[i] ) out[i] = is_last_in_group return out def summarize_targets( target_pack: TargetPack, ) -> dict[str, int | float]: """ Small debugging helper for logging. """ nt = target_pack.next_token uts = target_pack.unique_time_set n_tokens = int(len(nt.input_events)) n_readout = int(uts.readout_mask.sum()) n_positive_labels = int(uts.target_multi_hot.sum()) mean_set_size = ( float(n_positive_labels / n_readout) if n_readout > 0 else 0.0 ) return { "n_input_tokens": n_tokens, "n_uts_readouts": n_readout, "n_uts_positive_labels": n_positive_labels, "mean_uts_set_size": mean_set_size, }