Files
DeepHealth/compute_burden_index_landmarks.py

923 lines
33 KiB
Python
Raw Normal View History

from __future__ import annotations
import argparse
import multiprocessing as mp
2026-06-26 11:27:44 +08:00
import time
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Iterable
import numpy as np
import pandas as pd
2026-06-26 11:15:15 +08:00
import torch
from tqdm.auto import tqdm
2026-06-26 11:15:15 +08:00
from burden_index import (
_build_readout_grid,
_observed_formed_burden,
load_burden_context,
)
from evaluate_auc_v2 import (
make_eval_indices,
parse_float_list,
)
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX
def _parse_landmark_ages(args: argparse.Namespace) -> np.ndarray:
explicit = parse_float_list(args.landmark_ages)
if explicit:
ages = np.asarray(explicit, dtype=np.float32)
else:
ages = np.arange(
float(args.landmark_start),
float(args.landmark_stop) + 1e-6,
float(args.landmark_step),
dtype=np.float32,
)
if ages.size == 0:
raise ValueError("No landmark ages were provided.")
return ages
2026-06-26 11:15:15 +08:00
def _parse_horizon(value: Any) -> float:
horizon = float(value)
if horizon < 0:
raise ValueError(f"horizon must be non-negative, got {horizon}")
return horizon
def _format_horizon_for_filename(horizon: float) -> str:
text = f"{float(horizon):g}".replace("-", "m").replace(".", "p")
return f"h{text}"
def _parse_devices(args: argparse.Namespace) -> list[str | None]:
if args.devices is not None and str(args.devices).strip():
devices = [x.strip() for x in str(args.devices).split(",") if x.strip()]
if not devices:
raise ValueError("--devices was provided but no valid devices were parsed.")
return devices
return [args.device]
def _build_burden_matrix_from_mapping(
mapping_csv: Path,
*,
token_col: str,
category_id_col: str,
category_col: str,
key_area_col: str,
weight_col: str,
) -> tuple[np.ndarray, np.ndarray, pd.DataFrame]:
df = pd.read_csv(mapping_csv)
required = {token_col, category_id_col, category_col, weight_col}
missing = sorted(required - set(df.columns))
if missing:
raise ValueError(f"{mapping_csv} is missing required columns: {missing}")
df = df.copy()
df[token_col] = pd.to_numeric(df[token_col], errors="raise").astype(int)
raw_category = df[category_id_col]
numeric_category = pd.to_numeric(raw_category, errors="coerce")
if numeric_category.notna().all():
df["_burden_category_key"] = numeric_category.astype(int)
else:
df["_burden_category_key"] = raw_category.astype(str)
df[weight_col] = pd.to_numeric(df[weight_col], errors="raise").astype(float)
df = df[df[weight_col] != 0].copy()
if df.empty:
raise ValueError(f"{mapping_csv} has no non-zero burden weights.")
disease_ids = np.asarray(sorted(df[token_col].unique().tolist()), dtype=np.int64)
category_keys = sorted(df["_burden_category_key"].unique().tolist())
disease_pos = {int(token): j for j, token in enumerate(disease_ids.tolist())}
category_pos = {cat: i for i, cat in enumerate(category_keys)}
A = np.zeros((len(category_keys), disease_ids.size), dtype=np.float64)
for _, row in df.iterrows():
token = int(row[token_col])
cat = row["_burden_category_key"]
weight = float(row[weight_col])
A[category_pos[cat], disease_pos[token]] += weight
meta_cols = list(dict.fromkeys(["_burden_category_key", category_id_col, category_col]))
if key_area_col in df.columns:
meta_cols.append(key_area_col)
category_meta = (
df[meta_cols]
.drop_duplicates(subset=["_burden_category_key"])
.sort_values("_burden_category_key")
.reset_index(drop=True)
)
category_meta = category_meta.rename(
columns={
"_burden_category_key": "burden_dimension_id",
category_col: "burden_dimension",
key_area_col: "burden_key_area",
}
)
if category_id_col in category_meta.columns:
category_meta = category_meta.drop(columns=[category_id_col])
if "burden_key_area" not in category_meta.columns:
category_meta["burden_key_area"] = ""
return A, disease_ids, category_meta
def _parse_burden_types(value: str) -> list[str]:
out = [x.strip().lower() for x in str(value).split(",") if x.strip()]
if not out:
raise ValueError("burden_types must contain at least one value.")
valid = {"functional", "organ"}
unknown = sorted(set(out) - valid)
if unknown:
raise ValueError(f"Unknown burden_types {unknown}; valid values are {sorted(valid)}")
return list(dict.fromkeys(out))
def _load_mapping_specs(args: argparse.Namespace) -> list[dict[str, Any]]:
specs: list[dict[str, Any]] = []
for burden_type in _parse_burden_types(args.burden_types):
if burden_type == "functional":
path = Path(args.functional_mapping_csv)
specs.append(
{
"burden_type": "functional",
"mapping_csv": path,
"token_col": "token_id",
"category_id_col": "hfrm_category_id",
"category_col": "hfrm_category",
"key_area_col": "hfrm_key_area",
"weight_col": args.functional_weight_col,
}
)
elif burden_type == "organ":
path = Path(args.organ_mapping_csv)
specs.append(
{
"burden_type": "organ",
"mapping_csv": path,
"token_col": "token_id",
"category_id_col": "organ_system",
"category_col": "organ_system",
"key_area_col": "icd10_chapter_name",
"weight_col": "organ_weight",
}
)
for spec in specs:
if not spec["mapping_csv"].exists():
raise FileNotFoundError(
f"{spec['burden_type']} mapping_csv not found: {spec['mapping_csv']}"
)
return specs
def _eligible_landmark_rows(
dataset: Any,
subset_indices: np.ndarray,
landmark_ages: np.ndarray,
*,
min_history_events: int,
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
special = np.asarray([PAD_IDX, CHECKUP_IDX, NO_EVENT_IDX], dtype=np.int64)
for patient_id, dataset_index in enumerate(subset_indices.tolist()):
sample = dataset.samples[int(dataset_index)]
seq_event = np.asarray(sample["event_seq"], dtype=np.int64)
seq_time = np.asarray(sample["time_seq"], dtype=np.float32)
tgt_event = np.asarray(sample["target_event_seq"], dtype=np.int64)
tgt_time = np.asarray(sample["target_time_seq"], dtype=np.float32)
if seq_event.size == 0 or tgt_event.size == 0:
continue
full_event = np.concatenate([seq_event, tgt_event[-1:]])
full_time = np.concatenate([seq_time, tgt_time[-1:]])
followup_end = float(np.max(full_time))
for landmark_age in landmark_ages.tolist():
landmark_age = float(landmark_age)
if not (followup_end > landmark_age):
continue
prefix_mask = full_time <= np.float32(landmark_age)
if not np.any(prefix_mask):
continue
prefix_events = full_event[prefix_mask].astype(np.int64, copy=False)
prefix_times = full_time[prefix_mask].astype(np.float32, copy=False)
valid_history = ~np.isin(prefix_events, special)
if int(valid_history.sum()) < int(min_history_events):
continue
rows.append(
{
"patient_id": int(patient_id),
"dataset_index": int(dataset_index),
"sex": int(sample["sex"]),
"landmark_age": np.float32(landmark_age),
"t_query": np.float32(landmark_age),
"followup_end_time": np.float32(followup_end),
"event_seq": prefix_events,
"time_seq": prefix_times,
"other_type": np.asarray(sample["other_type"], dtype=np.int64),
"other_value": np.asarray(sample["other_value"], dtype=np.float32),
"other_value_kind": np.asarray(
sample["other_value_kind"], dtype=np.int64),
"other_time": np.asarray(sample["other_time"], dtype=np.float32),
}
)
return rows
def _config_split_indices(
n: int,
cfg: dict[str, Any],
eval_split: str,
subset_size: int,
) -> np.ndarray:
args = argparse.Namespace(
train_ratio=None,
val_ratio=None,
test_ratio=None,
seed=None,
eval_split=eval_split,
dataset_subset_size=subset_size if subset_size > 0 else None,
)
# make_eval_indices reads len(dataset), so a small shim is enough.
class _Sized:
def __len__(self) -> int:
return n
return make_eval_indices(_Sized(), args, cfg)
2026-06-26 11:15:15 +08:00
def _iter_readout_batches(
n: int,
batch_size: int,
) -> Iterable[slice]:
batch_size = max(1, int(batch_size))
for start in range(0, n, batch_size):
yield slice(start, min(start + batch_size, n))
@torch.inference_mode()
def _query_hidden_jobs(
*,
2026-06-26 11:15:15 +08:00
ctx: Any,
jobs: list[tuple[dict[str, Any], float]],
) -> torch.Tensor:
if not jobs:
return torch.empty(0, ctx.model.n_embd, device=ctx.device)
batch_size = len(jobs)
max_event_len = max(int(np.asarray(row["event_seq"]).size) for row, _ in jobs)
max_other_len = max(int(np.asarray(row["other_type"]).size) for row, _ in jobs)
event = np.full((batch_size, max_event_len), PAD_IDX, dtype=np.int64)
time = np.zeros((batch_size, max_event_len), dtype=np.float32)
other_type = np.zeros((batch_size, max_other_len), dtype=np.int64)
other_value = np.zeros((batch_size, max_other_len), dtype=np.float32)
other_value_kind = np.zeros((batch_size, max_other_len), dtype=np.int64)
other_time = np.zeros((batch_size, max_other_len), dtype=np.float32)
sex = np.zeros(batch_size, dtype=np.int64)
query_times = np.zeros(batch_size, dtype=np.float32)
for i, (row, query_time) in enumerate(jobs):
event_seq = np.asarray(row["event_seq"], dtype=np.int64)
time_seq = np.asarray(row["time_seq"], dtype=np.float32)
other_type_seq = np.asarray(row["other_type"], dtype=np.int64)
other_value_seq = np.asarray(row["other_value"], dtype=np.float32)
other_value_kind_seq = np.asarray(row["other_value_kind"], dtype=np.int64)
other_time_seq = np.asarray(row["other_time"], dtype=np.float32)
event[i, : event_seq.size] = event_seq
time[i, : time_seq.size] = time_seq
other_type[i, : other_type_seq.size] = other_type_seq
other_value[i, : other_value_seq.size] = other_value_seq
other_value_kind[i, : other_value_kind_seq.size] = other_value_kind_seq
other_time[i, : other_time_seq.size] = other_time_seq
sex[i] = int(row["sex"])
query_times[i] = np.float32(query_time)
event_t = torch.from_numpy(event).long().to(ctx.device)
return ctx.model(
event_seq=event_t,
time_seq=torch.from_numpy(time).float().to(ctx.device),
sex=torch.from_numpy(sex).long().to(ctx.device),
padding_mask=event_t > PAD_IDX,
t_query=torch.from_numpy(query_times).float().to(ctx.device),
other_type=torch.from_numpy(other_type).long().to(ctx.device),
other_value=torch.from_numpy(other_value).float().to(ctx.device),
other_value_kind=torch.from_numpy(other_value_kind).long().to(ctx.device),
other_time=torch.from_numpy(other_time).float().to(ctx.device),
target_mode="all_future",
)
def _build_readout_table(
*,
rows: list[dict[str, Any]],
formed_mode: str,
2026-06-26 11:15:15 +08:00
horizon: float,
) -> dict[str, Any]:
jobs: list[tuple[dict[str, Any], float]] = []
row_indices: list[int] = []
kinds: list[str] = []
deltas: list[float] = []
if formed_mode not in {"observed", "model_weighted"}:
raise ValueError(f"Unknown formed_mode={formed_mode!r}")
for row_idx, row in enumerate(rows):
if formed_mode == "model_weighted":
grid = _build_readout_grid(
event_seq=row["event_seq"],
time_seq=row["time_seq"],
other_type=row["other_type"],
other_time=row["other_time"],
t_query=float(row["t_query"]),
)
if grid.size > 0:
end_times = np.concatenate(
[grid[1:], np.asarray([row["t_query"]], dtype=np.float32)]
)
row_deltas = np.maximum(end_times - grid, 0.0).astype(np.float32)
valid = row_deltas > 0
for query_time, delta in zip(grid[valid].tolist(), row_deltas[valid].tolist()):
jobs.append((row, float(query_time)))
row_indices.append(row_idx)
kinds.append("formed")
deltas.append(float(delta))
if horizon > 0:
jobs.append((row, float(row["t_query"])))
row_indices.append(row_idx)
kinds.append("future")
deltas.append(float(horizon))
return {
"jobs": jobs,
"row_indices": np.asarray(row_indices, dtype=np.int64),
"kinds": np.asarray(kinds, dtype=object),
"deltas": np.asarray(deltas, dtype=np.float32),
}
2026-06-26 11:27:44 +08:00
@torch.inference_mode()
def _probabilities_from_hidden_torch(
*,
ctx: Any,
hidden: torch.Tensor,
disease_ids: np.ndarray,
deltas: np.ndarray,
) -> torch.Tensor:
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 = torch.nn.functional.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)
return -torch.expm1(-rate * exposure)
2026-06-26 11:15:15 +08:00
@torch.inference_mode()
def _readout_probabilities(
*,
ctx: Any,
2026-06-26 11:15:15 +08:00
readout_table: dict[str, Any],
union_disease_ids: np.ndarray,
readout_batch_size: int,
2026-06-26 11:27:44 +08:00
) -> torch.Tensor:
2026-06-26 11:15:15 +08:00
jobs = readout_table["jobs"]
if not jobs:
2026-06-26 11:27:44 +08:00
return torch.empty((0, union_disease_ids.size), dtype=torch.float32, device=ctx.device)
2026-06-26 11:15:15 +08:00
2026-06-26 11:27:44 +08:00
out = torch.empty((len(jobs), union_disease_ids.size), dtype=torch.float32, device=ctx.device)
2026-06-26 11:15:15 +08:00
deltas = np.asarray(readout_table["deltas"], dtype=np.float32)
for slc in _iter_readout_batches(len(jobs), readout_batch_size):
hidden = _query_hidden_jobs(ctx=ctx, jobs=jobs[slc])
2026-06-26 11:27:44 +08:00
out[slc] = _probabilities_from_hidden_torch(
2026-06-26 11:15:15 +08:00
ctx=ctx,
hidden=hidden,
disease_ids=union_disease_ids,
deltas=deltas[slc],
2026-06-26 11:27:44 +08:00
).to(dtype=out.dtype)
2026-06-26 11:15:15 +08:00
return out
def _observed_formed_for_rows(
*,
rows: list[dict[str, Any]],
union_disease_ids: np.ndarray,
) -> np.ndarray:
formed = np.zeros((len(rows), union_disease_ids.size), dtype=np.float64)
for row_idx, row in enumerate(rows):
formed[row_idx] = _observed_formed_burden(
disease_ids=union_disease_ids,
event_seq=row["event_seq"],
time_seq=row["time_seq"],
t_query=float(row["t_query"]),
)
return formed
def _reduce_readout_table_to_bi_rows(
*,
rows: list[dict[str, Any]],
horizon: float,
matrices: list[dict[str, Any]],
union_disease_ids: np.ndarray,
formed_mode: str,
readout_table: dict[str, Any],
2026-06-26 11:27:44 +08:00
readout_prob: torch.Tensor,
ctx: Any,
) -> list[dict[str, Any]]:
2026-06-26 11:15:15 +08:00
if formed_mode == "observed":
2026-06-26 11:27:44 +08:00
formed_by_row = torch.as_tensor(
_observed_formed_for_rows(
rows=rows,
union_disease_ids=union_disease_ids,
),
dtype=readout_prob.dtype,
device=ctx.device,
)
2026-06-26 11:15:15 +08:00
elif formed_mode == "model_weighted":
2026-06-26 11:27:44 +08:00
formed_by_row = torch.zeros(
(len(rows), union_disease_ids.size),
dtype=readout_prob.dtype,
device=ctx.device,
)
2026-06-26 11:15:15 +08:00
else:
raise ValueError(f"Unknown formed_mode={formed_mode!r}")
2026-06-26 11:27:44 +08:00
future_prob_by_row = torch.zeros(
(len(rows), union_disease_ids.size),
dtype=readout_prob.dtype,
device=ctx.device,
)
row_indices = torch.as_tensor(
np.asarray(readout_table["row_indices"], dtype=np.int64),
dtype=torch.long,
device=ctx.device,
)
2026-06-26 11:15:15 +08:00
kinds = np.asarray(readout_table["kinds"], dtype=object)
if formed_mode == "model_weighted":
2026-06-26 11:27:44 +08:00
survival_by_row = torch.ones(
(len(rows), union_disease_ids.size),
dtype=readout_prob.dtype,
device=ctx.device,
)
2026-06-26 11:15:15 +08:00
else:
survival_by_row = None
2026-06-26 11:27:44 +08:00
if readout_prob.numel() > 0:
kind_is_formed = torch.as_tensor(
np.asarray(kinds == "formed", dtype=np.bool_),
dtype=torch.bool,
device=ctx.device,
)
kind_is_future = torch.as_tensor(
np.asarray(kinds == "future", dtype=np.bool_),
dtype=torch.bool,
device=ctx.device,
)
if survival_by_row is not None and bool(kind_is_formed.any().item()):
formed_rows = row_indices[kind_is_formed]
formed_survival = 1.0 - readout_prob[kind_is_formed].clamp(0.0, 1.0)
if hasattr(survival_by_row, "scatter_reduce_"):
survival_by_row.scatter_reduce_(
dim=0,
index=formed_rows[:, None].expand_as(formed_survival),
src=formed_survival,
reduce="prod",
include_self=True,
)
else:
for job_idx in torch.nonzero(kind_is_formed, as_tuple=False).flatten().tolist():
survival_by_row[int(row_indices[job_idx].item())] *= (
1.0 - readout_prob[job_idx].clamp(0.0, 1.0)
)
if bool(kind_is_future.any().item()):
future_rows = row_indices[kind_is_future]
future_prob_by_row[future_rows] = readout_prob[kind_is_future]
2026-06-26 11:15:15 +08:00
if survival_by_row is not None:
formed_by_row = 1.0 - survival_by_row
2026-06-26 11:27:44 +08:00
disease_future_by_row = (1.0 - formed_by_row) * future_prob_by_row
disease_total_by_row = formed_by_row + disease_future_by_row
projected: list[dict[str, Any]] = []
for matrix in matrices:
A = torch.as_tensor(matrix["A_union"], dtype=readout_prob.dtype, device=ctx.device)
projected.append(
{
"matrix": matrix,
"historical": torch.matmul(formed_by_row, A.T).detach().cpu().numpy(),
"future": torch.matmul(disease_future_by_row, A.T).detach().cpu().numpy(),
"total": torch.matmul(disease_total_by_row, A.T).detach().cpu().numpy(),
}
)
2026-06-26 11:15:15 +08:00
out: list[dict[str, Any]] = []
for row_idx, row in enumerate(rows):
2026-06-26 11:27:44 +08:00
for item in projected:
matrix = item["matrix"]
historical = item["historical"][row_idx]
future = item["future"][row_idx]
total = item["total"][row_idx]
2026-06-26 11:15:15 +08:00
for dim_idx, meta in matrix["category_meta"].iterrows():
out.append(
{
"patient_id": row["patient_id"],
"dataset_index": row["dataset_index"],
"sex": row["sex"],
"landmark_age": float(row["landmark_age"]),
"t_query": float(row["t_query"]),
"followup_end_time": float(row["followup_end_time"]),
"horizon": float(horizon),
"formed_mode": formed_mode,
"burden_type": matrix["burden_type"],
"burden_dimension_id": meta["burden_dimension_id"],
"burden_dimension": str(meta["burden_dimension"]),
"burden_key_area": str(meta.get("burden_key_area", "")),
"bi_historical": float(historical[int(dim_idx)]),
"bi_future": float(future[int(dim_idx)]),
"bi_total": float(total[int(dim_idx)]),
}
)
return out
2026-06-26 11:15:15 +08:00
def _compute_bi_from_readout_table(
*,
rows: list[dict[str, Any]],
horizon: float,
matrices: list[dict[str, Any]],
union_disease_ids: np.ndarray,
formed_mode: str,
readout_batch_size: int,
ctx: Any,
2026-06-26 11:27:44 +08:00
) -> tuple[list[dict[str, Any]], int, dict[str, float]]:
2026-06-26 11:15:15 +08:00
horizon = float(horizon)
if horizon < 0:
raise ValueError(f"horizon must be non-negative, got {horizon}")
2026-06-26 11:27:44 +08:00
t0 = time.perf_counter()
2026-06-26 11:15:15 +08:00
readout_table = _build_readout_table(
rows=rows,
formed_mode=formed_mode,
horizon=horizon,
)
2026-06-26 11:27:44 +08:00
t1 = time.perf_counter()
2026-06-26 11:15:15 +08:00
readout_prob = _readout_probabilities(
ctx=ctx,
readout_table=readout_table,
union_disease_ids=union_disease_ids,
readout_batch_size=readout_batch_size,
)
2026-06-26 11:27:44 +08:00
if ctx.device.type == "cuda":
torch.cuda.synchronize(ctx.device)
t2 = time.perf_counter()
2026-06-26 11:15:15 +08:00
rows_out = _reduce_readout_table_to_bi_rows(
rows=rows,
horizon=horizon,
matrices=matrices,
union_disease_ids=union_disease_ids,
formed_mode=formed_mode,
readout_table=readout_table,
readout_prob=readout_prob,
2026-06-26 11:27:44 +08:00
ctx=ctx,
2026-06-26 11:15:15 +08:00
)
2026-06-26 11:27:44 +08:00
if ctx.device.type == "cuda":
torch.cuda.synchronize(ctx.device)
t3 = time.perf_counter()
timings = {
"build_readout_sec": t1 - t0,
"forward_sec": t2 - t1,
"reduce_sec": t3 - t2,
}
return rows_out, len(readout_table["jobs"]), timings
2026-06-26 11:15:15 +08:00
def _compute_chunk_worker(payload: dict[str, Any]) -> dict[str, Any]:
device = payload["device"]
run_path = Path(payload["run_path"])
ctx = load_burden_context(run_path, device=device)
2026-06-26 11:27:44 +08:00
out, readout_jobs, timings = _compute_bi_from_readout_table(
2026-06-26 11:15:15 +08:00
rows=payload["rows"],
horizon=payload["horizon"],
matrices=payload["matrices"],
union_disease_ids=payload["union_disease_ids"],
formed_mode=payload["formed_mode"],
readout_batch_size=int(payload["readout_batch_size"]),
ctx=ctx,
)
2026-06-26 11:27:44 +08:00
return {"rows": out, "readout_jobs": readout_jobs, "timings": timings}
2026-06-26 11:15:15 +08:00
def _attach_union_projection(
matrices: list[dict[str, Any]],
) -> tuple[np.ndarray, list[dict[str, Any]]]:
union_disease_ids = np.asarray(
sorted(
{
int(token)
for matrix in matrices
for token in np.asarray(matrix["disease_ids"], dtype=np.int64).tolist()
}
),
dtype=np.int64,
)
if union_disease_ids.size == 0:
raise ValueError("No disease tokens are covered by the requested burden matrices.")
union_pos = {int(token): i for i, token in enumerate(union_disease_ids.tolist())}
projected: list[dict[str, Any]] = []
for matrix in matrices:
disease_ids = np.asarray(matrix["disease_ids"], dtype=np.int64)
A = np.asarray(matrix["A"], dtype=np.float64)
A_union = np.zeros((A.shape[0], union_disease_ids.size), dtype=np.float64)
for local_col, token in enumerate(disease_ids.tolist()):
A_union[:, union_pos[int(token)]] += A[:, int(local_col)]
item = dict(matrix)
item["A_union"] = A_union
projected.append(item)
return union_disease_ids, projected
def _split_rows_for_devices(
rows: list[dict[str, Any]],
devices: list[str | None],
2026-06-26 11:27:44 +08:00
*,
formed_mode: str,
horizon: float,
) -> list[tuple[str | None, list[dict[str, Any]]]]:
if len(devices) <= 1:
return [(devices[0], rows)]
2026-06-26 11:27:44 +08:00
buckets: list[list[dict[str, Any]]] = [[] for _ in devices]
loads = np.zeros(len(devices), dtype=np.int64)
weighted_rows = sorted(
rows,
key=lambda row: _estimate_readout_jobs_for_row(
row,
formed_mode=formed_mode,
horizon=horizon,
),
reverse=True,
)
for row in weighted_rows:
bucket_idx = int(np.argmin(loads))
buckets[bucket_idx].append(row)
loads[bucket_idx] += _estimate_readout_jobs_for_row(
row,
formed_mode=formed_mode,
horizon=horizon,
)
chunks: list[tuple[str | None, list[dict[str, Any]]]] = []
2026-06-26 11:27:44 +08:00
for device, bucket in zip(devices, buckets):
if not bucket:
continue
2026-06-26 11:27:44 +08:00
chunks.append((device, bucket))
return chunks
2026-06-26 11:27:44 +08:00
def _estimate_readout_jobs_for_row(
row: dict[str, Any],
*,
formed_mode: str,
horizon: float,
) -> int:
n_jobs = 0
if formed_mode == "model_weighted":
grid = _build_readout_grid(
event_seq=row["event_seq"],
time_seq=row["time_seq"],
other_type=row["other_type"],
other_time=row["other_time"],
t_query=float(row["t_query"]),
)
if grid.size > 0:
end_times = np.concatenate([grid[1:], np.asarray([row["t_query"]], dtype=np.float32)])
n_jobs += int(np.sum(np.maximum(end_times - grid, 0.0) > 0))
if horizon > 0:
n_jobs += 1
return max(n_jobs, 1)
def main() -> None:
parser = argparse.ArgumentParser(
description="Compute DeepHealth Burden Indices at landmark ages."
)
parser.add_argument("--run_path", type=str, required=True)
parser.add_argument("--burden_types", type=str, default="functional,organ",
help="Comma-separated burden types to compute: functional,organ.")
parser.add_argument("--functional_mapping_csv", type=str,
default="cihi_hfrm_label_mapping.csv")
parser.add_argument("--organ_mapping_csv", type=str,
default="icd10_organ_label_mapping.csv")
parser.add_argument("--output_path", type=str, default=None)
parser.add_argument("--eval_split", type=str, default="test",
choices=["train", "val", "valid", "validation", "test", "all"])
parser.add_argument("--formed_mode", type=str, default="model_weighted",
choices=["observed", "model_weighted"])
2026-06-26 11:15:15 +08:00
parser.add_argument(
"--horizon",
type=float,
required=True,
help=(
"Future horizon in years. Use 0 to compute historical burden only "
"(bi_future=0 and bi_total=bi_historical)."
),
)
parser.add_argument("--landmark_ages", type=str, default=None)
parser.add_argument("--landmark_start", type=float, default=40.0)
parser.add_argument("--landmark_stop", type=float, default=80.0)
parser.add_argument("--landmark_step", type=float, default=5.0)
parser.add_argument("--min_history_events", type=int, default=1)
parser.add_argument("--dataset_subset_size", type=int, default=0)
2026-06-26 11:15:15 +08:00
parser.add_argument(
"--readout_batch_size",
type=int,
default=8192,
help=(
"Number of readout points forwarded together inside each worker. "
"Increase this to improve GPU utilization if memory allows."
),
)
parser.add_argument("--device", type=str, default=None)
parser.add_argument(
"--devices",
type=str,
default=None,
help=(
"Comma-separated devices for data-parallel BI computation, e.g. "
"'cuda:0,cuda:1'. Overrides --device when provided."
),
)
parser.add_argument("--functional_weight_col", type=str,
default="hfrm_normalized_weight")
args = parser.parse_args()
run_path = Path(args.run_path)
mapping_specs = _load_mapping_specs(args)
devices = _parse_devices(args)
initial_device = "cpu" if len(devices) > 1 else devices[0]
ctx = load_burden_context(run_path, device=initial_device)
matrices = []
for spec in mapping_specs:
A, disease_ids, category_meta = _build_burden_matrix_from_mapping(
spec["mapping_csv"],
token_col=spec["token_col"],
category_id_col=spec["category_id_col"],
category_col=spec["category_col"],
key_area_col=spec["key_area_col"],
weight_col=spec["weight_col"],
)
matrices.append(
{
"burden_type": spec["burden_type"],
"A": A,
"disease_ids": disease_ids,
"category_meta": category_meta,
}
)
2026-06-26 11:15:15 +08:00
union_disease_ids, matrices = _attach_union_projection(matrices)
landmark_ages = _parse_landmark_ages(args)
2026-06-26 11:15:15 +08:00
horizon = _parse_horizon(args.horizon)
eval_split = str(args.eval_split).lower()
if eval_split in {"valid", "validation"}:
eval_split = "val"
subset_indices = _config_split_indices(
len(ctx.dataset),
ctx.cfg,
eval_split,
int(args.dataset_subset_size),
)
rows = _eligible_landmark_rows(
ctx.dataset,
subset_indices,
landmark_ages,
min_history_events=int(args.min_history_events),
)
if not rows:
raise RuntimeError(
"No eligible landmark rows. Check eval split, landmark ages, and min_history_events."
)
output_path = Path(args.output_path) if args.output_path else (
2026-06-26 11:15:15 +08:00
run_path
/ f"burden_index_{eval_split}_{args.formed_mode}_{_format_horizon_for_filename(horizon)}.csv"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
all_rows: list[dict[str, Any]] = []
2026-06-26 11:15:15 +08:00
total_readout_jobs = 0
2026-06-26 11:27:44 +08:00
total_timings = {
"build_readout_sec": 0.0,
"forward_sec": 0.0,
"reduce_sec": 0.0,
"write_csv_sec": 0.0,
}
row_chunks = _split_rows_for_devices(
rows,
devices,
formed_mode=args.formed_mode,
horizon=horizon,
)
if len(row_chunks) == 1:
2026-06-26 11:27:44 +08:00
all_rows, total_readout_jobs, timings = _compute_bi_from_readout_table(
2026-06-26 11:15:15 +08:00
rows=rows,
horizon=horizon,
matrices=matrices,
union_disease_ids=union_disease_ids,
formed_mode=args.formed_mode,
readout_batch_size=int(args.readout_batch_size),
ctx=ctx,
)
2026-06-26 11:27:44 +08:00
for key, value in timings.items():
total_timings[key] += float(value)
else:
# The main-process context is only needed to build the dataset and rows.
# Workers load their own model copy on the assigned device.
del ctx
payloads = [
{
"device": device,
"run_path": str(run_path),
"rows": chunk_rows,
2026-06-26 11:15:15 +08:00
"horizon": horizon,
"matrices": matrices,
2026-06-26 11:15:15 +08:00
"union_disease_ids": union_disease_ids,
"readout_batch_size": int(args.readout_batch_size),
"formed_mode": args.formed_mode,
}
for device, chunk_rows in row_chunks
]
with ProcessPoolExecutor(
max_workers=len(payloads),
mp_context=mp.get_context("spawn"),
) as executor:
futures = [executor.submit(_compute_chunk_worker, p) for p in payloads]
for future in tqdm(
as_completed(futures),
total=len(futures),
desc="Computing BI chunks",
dynamic_ncols=True,
):
2026-06-26 11:15:15 +08:00
result = future.result()
all_rows.extend(result["rows"])
total_readout_jobs += int(result["readout_jobs"])
2026-06-26 11:27:44 +08:00
for key, value in result["timings"].items():
total_timings[key] += float(value)
2026-06-26 11:27:44 +08:00
write_start = time.perf_counter()
out_df = pd.DataFrame(all_rows)
out_df.to_csv(output_path, index=False)
2026-06-26 11:27:44 +08:00
total_timings["write_csv_sec"] = time.perf_counter() - write_start
print(f"Run path: {run_path}")
print(f"Eval split: {eval_split}")
2026-06-26 11:15:15 +08:00
print(f"Horizon: {horizon:g}")
print(f"Landmark rows: {len(rows)}")
2026-06-26 11:15:15 +08:00
print(f"Readout jobs: {total_readout_jobs}")
print(f"Readout batch size per worker: {int(args.readout_batch_size)}")
2026-06-26 11:27:44 +08:00
print(
"Timing seconds: "
f"build_readout={total_timings['build_readout_sec']:.2f}, "
f"forward={total_timings['forward_sec']:.2f}, "
f"reduce={total_timings['reduce_sec']:.2f}, "
f"write_csv={total_timings['write_csv_sec']:.2f}"
)
print(f"Devices: {', '.join(str(d) for d, _ in row_chunks)}")
for matrix in matrices:
print(
f"{matrix['burden_type']} dimensions: {matrix['A'].shape[0]}, "
f"mapped disease tokens: {matrix['A'].shape[1]}"
)
2026-06-26 11:15:15 +08:00
print(f"Union disease tokens evaluated once per sample: {union_disease_ids.size}")
print(f"Output: {output_path}")
if __name__ == "__main__":
main()