Files
DeepHealth/burden_index.py

548 lines
18 KiB
Python
Raw Normal View History

from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, Sequence
import numpy as np
import torch
import torch.nn.functional as F
from evaluate_auc_v2 import (
build_model_from_dataset,
load_checkpoint_state_dict,
load_json_config,
load_model_state,
resolve_dist_mode_for_checkpoint,
validate_dataset_metadata,
)
from eval_data import load_sequence_eval_dataset
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX
FormedBurdenMode = Literal["observed", "model_weighted"]
@dataclass(frozen=True)
class BurdenContext:
model: torch.nn.Module
dataset: Any
cfg: dict[str, Any]
dist_mode: str
device: torch.device
run_path: Path
@dataclass(frozen=True)
class DiseaseBurdenResult:
disease_id: int
formed: float
future: float
total: float
formed_mode: str
horizon: float
@dataclass(frozen=True)
class BurdenIndexResult:
historical: np.ndarray
future: np.ndarray
total: np.ndarray
disease_ids: np.ndarray
formed: np.ndarray
disease_future: np.ndarray
disease_total: np.ndarray
formed_mode: str
horizon: float
def load_burden_context(
run_path: str | Path,
*,
device: str | torch.device | None = None,
) -> BurdenContext:
run_path = Path(run_path)
config_path = run_path / "train_config.json"
model_ckpt_path = run_path / "best_model.pt"
if not config_path.exists():
raise FileNotFoundError(f"train_config.json not found in {run_path}")
if not model_ckpt_path.exists():
raise FileNotFoundError(f"best_model.pt not found in {run_path}")
cfg = load_json_config(config_path)
model_target_mode = str(cfg.get("model_target_mode", "next_token")).lower()
if model_target_mode == "next_token":
raise RuntimeError(
"Burden Index computation requires an all_future checkpoint because "
"it uses p_d(h, Delta). The provided run is model_target_mode='next_token'."
)
if model_target_mode != "all_future":
raise ValueError(
"train_config.json model_target_mode must be all_future for burden "
f"computation, got {model_target_mode!r}."
)
device_obj = torch.device(
device if device is not None else ("cuda" if torch.cuda.is_available() else "cpu")
)
if device_obj.type == "cuda" and not torch.cuda.is_available():
raise RuntimeError(f"Requested device {device_obj}, but CUDA is not available.")
data_prefix = cfg.get("data_prefix", "ukb")
labels_file = cfg.get("labels_file", "labels.csv")
extra_info_types = cfg.get("extra_info_types", None)
dataset = load_sequence_eval_dataset(
model_target_mode="all_future",
data_prefix=data_prefix,
labels_file=labels_file,
no_event_interval_years=float(cfg.get("no_event_interval_years", 5.0)),
include_no_event_in_uts_target=bool(
cfg.get("include_no_event_in_uts_target", False)
),
min_history_events=int(cfg.get("all_future_min_history_events", 1)),
min_future_events=int(cfg.get("all_future_min_future_events", 1)),
extra_info_types=extra_info_types,
)
validate_dataset_metadata(dataset, cfg)
state_dict = load_checkpoint_state_dict(model_ckpt_path, map_location="cpu")
dist_mode = resolve_dist_mode_for_checkpoint(
str(cfg.get("dist_mode", "exponential")),
state_dict,
)
if dist_mode not in {"exponential", "weibull", "mixed"}:
raise ValueError(f"Unsupported dist_mode={dist_mode!r}")
cfg_model = dict(cfg)
cfg_model["dist_mode"] = dist_mode
args = _ConfigNamespace()
model = build_model_from_dataset(args, cfg_model, dataset)
load_model_state(model, state_dict)
model.eval().to(device_obj)
return BurdenContext(
model=model,
dataset=dataset,
cfg=cfg,
dist_mode=dist_mode,
device=device_obj,
run_path=run_path,
)
@torch.inference_mode()
def compute_disease_burden(
*,
run_path: str | Path,
disease_id: int,
event_seq: Sequence[int] | np.ndarray,
time_seq: Sequence[float] | np.ndarray,
sex: int,
other_type: Sequence[int] | np.ndarray,
other_value: Sequence[float] | np.ndarray,
other_value_kind: Sequence[int] | np.ndarray,
other_time: Sequence[float] | np.ndarray,
t_query: float,
horizon: float,
formed_mode: FormedBurdenMode,
device: str | torch.device | None = None,
context: BurdenContext | None = None,
) -> DiseaseBurdenResult:
ctx = context or load_burden_context(run_path, device=device)
disease_ids = np.asarray([int(disease_id)], dtype=np.int64)
formed, future_prob = _compute_disease_components(
ctx=ctx,
disease_ids=disease_ids,
event_seq=event_seq,
time_seq=time_seq,
sex=sex,
other_type=other_type,
other_value=other_value,
other_value_kind=other_value_kind,
other_time=other_time,
t_query=float(t_query),
horizon=float(horizon),
formed_mode=formed_mode,
)
future = (1.0 - formed) * future_prob
total = formed + future
return DiseaseBurdenResult(
disease_id=int(disease_id),
formed=float(formed[0]),
future=float(future[0]),
total=float(total[0]),
formed_mode=str(formed_mode),
horizon=float(horizon),
)
@torch.inference_mode()
def compute_burden_index(
*,
run_path: str | Path,
burden_matrix: np.ndarray,
disease_ids: Sequence[int] | np.ndarray,
event_seq: Sequence[int] | np.ndarray,
time_seq: Sequence[float] | np.ndarray,
sex: int,
other_type: Sequence[int] | np.ndarray,
other_value: Sequence[float] | np.ndarray,
other_value_kind: Sequence[int] | np.ndarray,
other_time: Sequence[float] | np.ndarray,
t_query: float,
horizon: float,
formed_mode: FormedBurdenMode,
device: str | torch.device | None = None,
context: BurdenContext | None = None,
) -> BurdenIndexResult:
ctx = context or load_burden_context(run_path, device=device)
disease_ids_arr = np.asarray(disease_ids, dtype=np.int64)
if disease_ids_arr.ndim != 1 or disease_ids_arr.size == 0:
raise ValueError("disease_ids must be a non-empty 1D sequence.")
A = np.asarray(burden_matrix, dtype=np.float64)
if A.ndim != 2:
raise ValueError(f"burden_matrix must be 2D, got shape {A.shape}")
if A.shape[1] != disease_ids_arr.size:
raise ValueError(
"burden_matrix columns must match disease_ids length, got "
f"{A.shape[1]} columns vs {disease_ids_arr.size} disease ids."
)
formed, future_prob = _compute_disease_components(
ctx=ctx,
disease_ids=disease_ids_arr,
event_seq=event_seq,
time_seq=time_seq,
sex=sex,
other_type=other_type,
other_value=other_value,
other_value_kind=other_value_kind,
other_time=other_time,
t_query=float(t_query),
horizon=float(horizon),
formed_mode=formed_mode,
)
disease_future = (1.0 - formed) * future_prob
disease_total = formed + disease_future
historical = A @ formed
future = A @ disease_future
total = A @ disease_total
return BurdenIndexResult(
historical=historical,
future=future,
total=total,
disease_ids=disease_ids_arr.copy(),
formed=formed,
disease_future=disease_future,
disease_total=disease_total,
formed_mode=str(formed_mode),
horizon=float(horizon),
)
class _ConfigNamespace:
def __getattr__(self, _name: str) -> None:
return None
def _compute_disease_components(
*,
ctx: BurdenContext,
disease_ids: np.ndarray,
event_seq: Sequence[int] | np.ndarray,
time_seq: Sequence[float] | np.ndarray,
sex: int,
other_type: Sequence[int] | np.ndarray,
other_value: Sequence[float] | np.ndarray,
other_value_kind: Sequence[int] | np.ndarray,
other_time: Sequence[float] | np.ndarray,
t_query: float,
horizon: float,
formed_mode: FormedBurdenMode,
) -> tuple[np.ndarray, np.ndarray]:
formed_mode = _validate_formed_mode(formed_mode)
disease_ids = np.asarray(disease_ids, dtype=np.int64)
_validate_disease_ids(ctx, disease_ids)
event_seq_arr, time_seq_arr = _validate_event_inputs(event_seq, time_seq)
other_type_arr, other_value_arr, other_value_kind_arr, other_time_arr = (
_validate_other_inputs(other_type, other_value, other_value_kind, other_time)
)
if horizon < 0:
raise ValueError(f"horizon must be non-negative, got {horizon}")
if formed_mode == "observed":
formed = _observed_formed_burden(
disease_ids=disease_ids,
event_seq=event_seq_arr,
time_seq=time_seq_arr,
t_query=t_query,
)
else:
formed = _model_weighted_formed_burden(
ctx=ctx,
disease_ids=disease_ids,
event_seq=event_seq_arr,
time_seq=time_seq_arr,
sex=sex,
other_type=other_type_arr,
other_value=other_value_arr,
other_value_kind=other_value_kind_arr,
other_time=other_time_arr,
t_query=t_query,
)
hidden_query = _query_hidden(
ctx=ctx,
event_seq=event_seq_arr,
time_seq=time_seq_arr,
sex=sex,
other_type=other_type_arr,
other_value=other_value_arr,
other_value_kind=other_value_kind_arr,
other_time=other_time_arr,
query_times=np.asarray([t_query], dtype=np.float32),
)
future_prob = _probabilities_from_hidden(
ctx=ctx,
hidden=hidden_query,
disease_ids=disease_ids,
deltas=np.asarray([horizon], dtype=np.float32),
)[0]
return formed.astype(np.float64), future_prob.astype(np.float64)
def _model_weighted_formed_burden(
*,
ctx: BurdenContext,
disease_ids: np.ndarray,
event_seq: np.ndarray,
time_seq: np.ndarray,
sex: int,
other_type: np.ndarray,
other_value: np.ndarray,
other_value_kind: np.ndarray,
other_time: np.ndarray,
t_query: float,
) -> np.ndarray:
grid = _build_readout_grid(
event_seq=event_seq,
time_seq=time_seq,
other_type=other_type,
other_time=other_time,
t_query=t_query,
)
if grid.size == 0:
return np.zeros(disease_ids.size, dtype=np.float64)
end_times = np.concatenate([grid[1:], np.asarray([t_query], dtype=np.float32)])
deltas = np.maximum(end_times - grid, 0.0).astype(np.float32)
if np.all(deltas <= 0):
return np.zeros(disease_ids.size, dtype=np.float64)
hidden = _query_hidden(
ctx=ctx,
event_seq=event_seq,
time_seq=time_seq,
sex=sex,
other_type=other_type,
other_value=other_value,
other_value_kind=other_value_kind,
other_time=other_time,
query_times=grid.astype(np.float32),
)
interval_prob = _probabilities_from_hidden(
ctx=ctx,
hidden=hidden,
disease_ids=disease_ids,
deltas=deltas,
).astype(np.float64)
survival = np.prod(1.0 - np.clip(interval_prob, 0.0, 1.0), axis=0)
return 1.0 - survival
def _observed_formed_burden(
*,
disease_ids: np.ndarray,
event_seq: np.ndarray,
time_seq: np.ndarray,
t_query: float,
) -> np.ndarray:
valid = (
(time_seq <= np.float32(t_query))
& (event_seq > PAD_IDX)
& (event_seq != CHECKUP_IDX)
& (event_seq != NO_EVENT_IDX)
)
observed = set(int(x) for x in event_seq[valid].tolist())
return np.asarray([1.0 if int(d) in observed else 0.0 for d in disease_ids])
def _build_readout_grid(
*,
event_seq: np.ndarray,
time_seq: np.ndarray,
other_type: np.ndarray,
other_time: np.ndarray,
t_query: float,
) -> np.ndarray:
event_mask = (event_seq > PAD_IDX) & (time_seq <= np.float32(t_query))
other_mask = (other_type > 0) & (other_time <= np.float32(t_query))
times = np.concatenate(
[
time_seq[event_mask].astype(np.float32, copy=False),
other_time[other_mask].astype(np.float32, copy=False),
]
)
if times.size == 0:
return np.zeros(0, dtype=np.float32)
return np.unique(times)
def _query_hidden(
*,
ctx: BurdenContext,
event_seq: np.ndarray,
time_seq: np.ndarray,
sex: int,
other_type: np.ndarray,
other_value: np.ndarray,
other_value_kind: np.ndarray,
other_time: np.ndarray,
query_times: np.ndarray,
) -> torch.Tensor:
if query_times.ndim != 1:
raise ValueError("query_times must be 1D.")
batch_size = int(query_times.size)
if batch_size == 0:
return torch.empty(0, ctx.model.n_embd, device=ctx.device)
event = torch.from_numpy(event_seq[None, :].repeat(batch_size, axis=0)).long()
times = torch.from_numpy(time_seq[None, :].repeat(batch_size, axis=0)).float()
other_t = torch.from_numpy(other_type[None, :].repeat(batch_size, axis=0)).long()
other_v = torch.from_numpy(other_value[None, :].repeat(batch_size, axis=0)).float()
other_k = torch.from_numpy(
other_value_kind[None, :].repeat(batch_size, axis=0)
).long()
other_tm = torch.from_numpy(other_time[None, :].repeat(batch_size, axis=0)).float()
sex_t = torch.full((batch_size,), int(sex), dtype=torch.long)
tq = torch.from_numpy(query_times.astype(np.float32, copy=False)).float()
event = event.to(ctx.device)
times = times.to(ctx.device)
other_t = other_t.to(ctx.device)
other_v = other_v.to(ctx.device)
other_k = other_k.to(ctx.device)
other_tm = other_tm.to(ctx.device)
sex_t = sex_t.to(ctx.device)
tq = tq.to(ctx.device)
return ctx.model(
event_seq=event,
time_seq=times,
sex=sex_t,
padding_mask=event > PAD_IDX,
t_query=tq,
other_type=other_t,
other_value=other_v,
other_value_kind=other_k,
other_time=other_tm,
target_mode="all_future",
)
def _probabilities_from_hidden(
*,
ctx: BurdenContext,
hidden: torch.Tensor,
disease_ids: np.ndarray,
deltas: np.ndarray,
) -> np.ndarray:
if hidden.ndim != 2:
raise ValueError(f"hidden must have shape (N, H), got {tuple(hidden.shape)}")
if deltas.ndim != 1 or deltas.size != hidden.shape[0]:
raise ValueError(
"deltas must be 1D with the same length as hidden rows, got "
f"{deltas.shape} vs {tuple(hidden.shape)}"
)
ids = torch.as_tensor(disease_ids, dtype=torch.long, device=ctx.device)
logits = ctx.model.calc_risk(hidden)[:, ids]
rate = F.softplus(logits).clamp_min(1e-8)
delta_t = torch.as_tensor(deltas, dtype=rate.dtype, device=ctx.device).clamp_min(0)
if ctx.dist_mode == "weibull":
rho = ctx.model.calc_weibull_rho(hidden)[:, ids]
exposure = torch.pow(delta_t[:, None], rho)
elif ctx.dist_mode == "mixed":
exposure = delta_t[:, None].expand_as(rate)
death_idx = int(getattr(ctx.model, "death_idx", getattr(ctx.model, "vocab_size", 0) - 1))
death_cols = [j for j, token in enumerate(disease_ids.tolist()) if int(token) == death_idx]
if death_cols:
death_rho = ctx.model.calc_death_rho(hidden)
for col in death_cols:
exposure[:, int(col)] = torch.pow(delta_t, death_rho)
else:
exposure = delta_t[:, None].expand_as(rate)
prob = -torch.expm1(-rate * exposure)
return prob.detach().cpu().numpy().astype(np.float64, copy=False)
def _validate_formed_mode(mode: str) -> FormedBurdenMode:
if mode not in {"observed", "model_weighted"}:
raise ValueError(
"formed_mode must be either 'observed' or 'model_weighted', "
f"got {mode!r}."
)
return mode # type: ignore[return-value]
def _validate_disease_ids(ctx: BurdenContext, disease_ids: np.ndarray) -> None:
if disease_ids.ndim != 1 or disease_ids.size == 0:
raise ValueError("disease_ids must be a non-empty 1D array.")
vocab_size = int(getattr(ctx.model, "vocab_size", ctx.model.risk_head.out_features))
if np.any(disease_ids < 0) or np.any(disease_ids >= vocab_size):
raise ValueError(f"disease_ids must be in [0, {vocab_size}), got {disease_ids}")
def _validate_event_inputs(
event_seq: Sequence[int] | np.ndarray,
time_seq: Sequence[float] | np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
events = np.asarray(event_seq, dtype=np.int64)
times = np.asarray(time_seq, dtype=np.float32)
if events.ndim != 1 or times.ndim != 1:
raise ValueError("event_seq and time_seq must be 1D.")
if events.shape != times.shape:
raise ValueError(
f"event_seq and time_seq must have the same shape, got {events.shape} vs {times.shape}"
)
if events.size == 0:
raise ValueError("event_seq must contain at least one token.")
if not np.all(np.isfinite(times)):
raise ValueError("time_seq contains non-finite values.")
return events, times
def _validate_other_inputs(
other_type: Sequence[int] | np.ndarray,
other_value: Sequence[float] | np.ndarray,
other_value_kind: Sequence[int] | np.ndarray,
other_time: Sequence[float] | np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
typ = np.asarray(other_type, dtype=np.int64)
val = np.asarray(other_value, dtype=np.float32)
kind = np.asarray(other_value_kind, dtype=np.int64)
tm = np.asarray(other_time, dtype=np.float32)
if not (typ.shape == val.shape == kind.shape == tm.shape):
raise ValueError(
"other_type, other_value, other_value_kind, and other_time must "
f"have the same shape, got {typ.shape}, {val.shape}, {kind.shape}, {tm.shape}."
)
if typ.ndim != 1:
raise ValueError("other_* inputs must be 1D.")
if not np.all(np.isfinite(tm)):
raise ValueError("other_time contains non-finite values.")
return typ, val, kind, tm