395 lines
11 KiB
Python
395 lines
11 KiB
Python
|
|
# 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 <NO_EVENT> 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,
|
||
|
|
}
|