Refactor AUC evaluation scripts to support model target modes and improve distribution handling
This commit is contained in:
30
README.md
30
README.md
@@ -261,11 +261,17 @@ python train.py --extra_info_types_file extra_info_types_smoking_alcohol_bmi.txt
|
|||||||
|
|
||||||
`evaluate_auc.py` 评估的是 **next-step / token-level 预测位置上的疾病 AUC**。
|
`evaluate_auc.py` 评估的是 **next-step / token-level 预测位置上的疾病 AUC**。
|
||||||
|
|
||||||
|
查询方式由 `train_config.json` 中的 `model_target_mode` 决定:
|
||||||
|
|
||||||
|
- `model_target_mode="next_token"`:使用训练 readout 对应的历史 token hidden 作为预测点。
|
||||||
|
- `model_target_mode="all_future"`:不使用 readout token,直接把每个预测点年龄作为 `t_query` 传入模型,取 query hidden 作为预测点。
|
||||||
|
|
||||||
核心流程:
|
核心流程:
|
||||||
|
|
||||||
- 按训练配置重新构建 `HealthDataset` 和 `DeepHealth`。
|
- 按训练配置重新构建 `HealthDataset` 和 `DeepHealth`。
|
||||||
- 对评估 split 中的患者做一次模型推理,缓存每个 disease-token readout hidden。
|
- 对评估 split 中的患者做模型推理,缓存每个预测点的 hidden。
|
||||||
- 对疾病 token 分块投影到 `risk_head`,避免一次性保存全词表 logits。
|
- 对疾病 token 分块投影到 `risk_head`,避免一次性保存全词表 logits。
|
||||||
|
- AUC score 使用疾病对应的 eta/logit 排序分数;`dist_mode` 只用于正确构建模型,不会把分数转换成 horizon-specific risk probability。
|
||||||
- 对每个疾病、性别、年龄段、prediction offset 分别计算 AUC。
|
- 对每个疾病、性别、年龄段、prediction offset 分别计算 AUC。
|
||||||
- 输出未池化分层结果和按疾病汇总后的结果。
|
- 输出未池化分层结果和按疾病汇总后的结果。
|
||||||
|
|
||||||
@@ -294,13 +300,20 @@ python evaluate_auc.py \
|
|||||||
|
|
||||||
`evaluate_auc_v2.py` 评估的是 **landmark fixed-horizon incident disease AUC**。
|
`evaluate_auc_v2.py` 评估的是 **landmark fixed-horizon incident disease AUC**。
|
||||||
|
|
||||||
它不是使用已有序列中的普通 readout 位置,而是在指定 landmark age 人工插入一个 `<NO_EVENT>` query token,然后评估该 landmark 后固定 horizon 内是否发生 incident disease。
|
它不是使用已有序列中的普通 readout 位置,而是在指定 landmark age 构造一个 landmark query,然后评估该 landmark 后固定 horizon 内是否发生 incident disease。
|
||||||
|
|
||||||
|
查询方式由 `train_config.json` 中的 `model_target_mode` 决定:
|
||||||
|
|
||||||
|
- `model_target_mode="next_token"`:在 landmark age 人工插入一个 `<NO_EVENT>` token,取该 token 的 hidden 做风险分数。
|
||||||
|
- `model_target_mode="all_future"`:不插入 `<NO_EVENT>`,直接把 landmark age 作为 `t_query` 传入模型,取 query hidden 做风险分数。
|
||||||
|
|
||||||
核心流程:
|
核心流程:
|
||||||
|
|
||||||
- 为每个患者和 landmark age 构造 landmark query 样本。
|
- 为每个患者和 landmark age 构造 landmark query 样本。
|
||||||
- 在 landmark age 插入 `<NO_EVENT>` token,取该位置 hidden。
|
- 根据模型模式插入 `<NO_EVENT>` token 或直接传 `t_query`,取 landmark/query hidden。
|
||||||
- 对疾病 token 分块投影到 `risk_head`。
|
- 对疾病 token 分块投影到 `risk_head`;`score_mode="risk"` 时会根据 `dist_mode` 把线性输出转换为固定 horizon 风险概率。
|
||||||
|
- 分布转换规则与 all-future 训练损失一致:`exponential` 使用 `1 - exp(-rate * horizon)`;`weibull` 使用 `1 - exp(-rate * horizon ** rho)`;`mixed` 中普通疾病使用 exponential,死亡 endpoint 使用 Weibull death rho。
|
||||||
|
- `score_mode="eta"` 是诊断用排序分数,不使用 `rho`,因此不区分不同分布的风险曲线。
|
||||||
- 按疾病、性别、landmark age、horizon 计算 incident disease AUC。
|
- 按疾病、性别、landmark age、horizon 计算 incident disease AUC。
|
||||||
- 可选择排除 horizon 内先于目标疾病发生的死亡竞争风险。
|
- 可选择排除 horizon 内先于目标疾病发生的死亡竞争风险。
|
||||||
|
|
||||||
@@ -333,8 +346,9 @@ python evaluate_auc_v2.py \
|
|||||||
| 项目 | `evaluate_auc.py` | `evaluate_auc_v2.py` |
|
| 项目 | `evaluate_auc.py` | `evaluate_auc_v2.py` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 评估口径 | next-step/token-level 预测点 | landmark fixed-horizon incident risk |
|
| 评估口径 | next-step/token-level 预测点 | landmark fixed-horizon incident risk |
|
||||||
| 查询位置 | 原始序列中满足 offset 条件的最新 readout token | 人工插入的 `<NO_EVENT>` landmark token |
|
| 查询位置 | next-token 用满足 offset 条件的最新 readout token;all-future 直接用该预测点年龄作为 `t_query` | next-token 用人工插入的 `<NO_EVENT>` landmark token;all-future 直接用 `t_query` |
|
||||||
| 时间参数 | `offsets`:预测点至少早于目标事件多少年 | `landmark_*` 和 `horizons`:固定年龄点与未来窗口 |
|
| 时间参数 | `offsets`:预测点至少早于目标事件多少年 | `landmark_*` 和 `horizons`:固定年龄点与未来窗口 |
|
||||||
|
| score 与分布 | 使用 eta/logit 排序分数;不按 `dist_mode` 转换风险概率 | `score_mode="risk"` 按 `dist_mode` 区分 exponential / Weibull / mixed;`score_mode="eta"` 不区分分布 |
|
||||||
| 病例定义 | target table 中出现目标疾病的患者/事件 | landmark 后 horizon 内首次发生目标疾病 |
|
| 病例定义 | target table 中出现目标疾病的患者/事件 | landmark 后 horizon 内首次发生目标疾病 |
|
||||||
| 对照定义 | 从未出现该疾病的患者的 eligible target occurrence | landmark 时未患病,且 horizon 内未发病并有足够随访 |
|
| 对照定义 | 从未出现该疾病的患者的 eligible target occurrence | landmark 时未患病,且 horizon 内未发病并有足够随访 |
|
||||||
| 分层 | sex + age bracket + offset | sex + landmark age + horizon |
|
| 分层 | sex + age bracket + offset | sex + landmark age + horizon |
|
||||||
@@ -380,7 +394,11 @@ python evaluate_auc_v2.py \
|
|||||||
- `evaluate_auc.py`
|
- `evaluate_auc.py`
|
||||||
- next-step/token-level 疾病 AUC 评估
|
- next-step/token-level 疾病 AUC 评估
|
||||||
- 使用 prediction offset、sex、age bracket 分层
|
- 使用 prediction offset、sex、age bracket 分层
|
||||||
|
- next-token 模型使用 readout hidden;all-future 模型使用 `t_query` hidden
|
||||||
|
- score 是疾病 eta/logit,不按分布转换为固定 horizon 风险概率
|
||||||
|
|
||||||
- `evaluate_auc_v2.py`
|
- `evaluate_auc_v2.py`
|
||||||
- landmark fixed-horizon incident disease AUC 评估
|
- landmark fixed-horizon incident disease AUC 评估
|
||||||
- 通过插入 `<NO_EVENT>` landmark token 查询固定年龄点风险
|
- next-token 模型通过插入 `<NO_EVENT>` landmark token 查询固定年龄点风险
|
||||||
|
- all-future 模型直接通过 `t_query` 查询固定年龄点风险
|
||||||
|
- `score_mode="risk"` 按 exponential / Weibull / mixed 分布计算固定 horizon 风险
|
||||||
|
|||||||
123
evaluate_auc.py
123
evaluate_auc.py
@@ -309,6 +309,12 @@ def split_indices(n: int, train_ratio: float, val_ratio: float, test_ratio: floa
|
|||||||
|
|
||||||
|
|
||||||
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
|
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
|
||||||
|
model_target_mode = str(cfg_get(
|
||||||
|
args, cfg, "model_target_mode", "next_token")).lower()
|
||||||
|
if model_target_mode not in {"next_token", "all_future"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"model_target_mode must be next_token or all_future, got {model_target_mode!r}"
|
||||||
|
)
|
||||||
return DeepHealth(
|
return DeepHealth(
|
||||||
vocab_size=dataset.vocab_size,
|
vocab_size=dataset.vocab_size,
|
||||||
n_embd=int(cfg_get(args, cfg, "n_embd", 120)),
|
n_embd=int(cfg_get(args, cfg, "n_embd", 120)),
|
||||||
@@ -320,7 +326,7 @@ def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], data
|
|||||||
n_categories=dataset.n_categories,
|
n_categories=dataset.n_categories,
|
||||||
cont_type_ids=dataset.cont_type_ids,
|
cont_type_ids=dataset.cont_type_ids,
|
||||||
n_bins=int(cfg_get(args, cfg, "n_bins", 16)),
|
n_bins=int(cfg_get(args, cfg, "n_bins", 16)),
|
||||||
target_mode="next_token",
|
target_mode=model_target_mode,
|
||||||
time_mode=str(cfg_get(args, cfg, "time_mode", "relative")),
|
time_mode=str(cfg_get(args, cfg, "time_mode", "relative")),
|
||||||
dist_mode=str(cfg_get(args, cfg, "dist_mode", "exponential")),
|
dist_mode=str(cfg_get(args, cfg, "dist_mode", "exponential")),
|
||||||
dropout=float(cfg_get(args, cfg, "dropout", 0.0)),
|
dropout=float(cfg_get(args, cfg, "dropout", 0.0)),
|
||||||
@@ -348,15 +354,25 @@ def resolve_dist_mode_for_checkpoint(cfg_dist_mode: str, state_dict: Dict[str, A
|
|||||||
mode = str(cfg_dist_mode).lower()
|
mode = str(cfg_dist_mode).lower()
|
||||||
has_rho_head = any(str(k).startswith("rho_head.")
|
has_rho_head = any(str(k).startswith("rho_head.")
|
||||||
for k in state_dict.keys())
|
for k in state_dict.keys())
|
||||||
|
has_rho_death_head = any(str(k).startswith("rho_death_head.")
|
||||||
|
for k in state_dict.keys())
|
||||||
|
|
||||||
if has_rho_head and mode != "weibull":
|
if has_rho_head and mode != "weibull":
|
||||||
print(
|
print(
|
||||||
"[WARN] Checkpoint contains rho_head weights; overriding dist_mode to 'weibull' for evaluation.")
|
"[WARN] Checkpoint contains rho_head weights; overriding dist_mode to 'weibull' for evaluation.")
|
||||||
return "weibull"
|
return "weibull"
|
||||||
|
if has_rho_death_head and mode != "mixed":
|
||||||
|
print(
|
||||||
|
"[WARN] Checkpoint contains rho_death_head weights; overriding dist_mode to 'mixed' for evaluation.")
|
||||||
|
return "mixed"
|
||||||
if (not has_rho_head) and mode == "weibull":
|
if (not has_rho_head) and mode == "weibull":
|
||||||
print(
|
print(
|
||||||
"[WARN] dist_mode is 'weibull' but checkpoint has no rho_head weights; overriding dist_mode to 'exponential'.")
|
"[WARN] dist_mode is 'weibull' but checkpoint has no rho_head weights; overriding dist_mode to 'exponential'.")
|
||||||
return "exponential"
|
return "exponential"
|
||||||
|
if (not has_rho_death_head) and mode == "mixed":
|
||||||
|
print(
|
||||||
|
"[WARN] dist_mode is 'mixed' but checkpoint has no rho_death_head weights; overriding dist_mode to 'exponential'.")
|
||||||
|
return "exponential"
|
||||||
return mode
|
return mode
|
||||||
|
|
||||||
|
|
||||||
@@ -452,25 +468,27 @@ def infer_readout_hidden(
|
|||||||
model: DeepHealth,
|
model: DeepHealth,
|
||||||
loader: DataLoader,
|
loader: DataLoader,
|
||||||
device: torch.device,
|
device: torch.device,
|
||||||
|
model_target_mode: str,
|
||||||
readout_name: str,
|
readout_name: str,
|
||||||
readout_reduce: str,
|
readout_reduce: str,
|
||||||
use_amp: bool,
|
use_amp: bool,
|
||||||
hidden_cache_dtype: str = "float16",
|
hidden_cache_dtype: str = "float16",
|
||||||
) -> Tuple[np.ndarray, Dict[str, np.ndarray]]:
|
) -> Tuple[np.ndarray, Dict[str, np.ndarray]]:
|
||||||
"""
|
"""Cache per-position hidden states used by the unchanged AUC logic."""
|
||||||
Run the expensive transformer/readout path exactly once and cache hidden states.
|
model_target_mode = str(model_target_mode).lower()
|
||||||
|
if model_target_mode not in {"next_token", "all_future"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"model_target_mode must be next_token or all_future, got {model_target_mode!r}"
|
||||||
|
)
|
||||||
|
|
||||||
The older implementation re-ran the whole model once per disease chunk even
|
readout = None
|
||||||
though only the final tied risk-head columns changed. That is usually the
|
if model_target_mode == "next_token" and readout_name == "same_time_group_end":
|
||||||
dominant bottleneck. This function computes readout hidden states once; later
|
|
||||||
chunks only perform a cheap selected-vocabulary linear projection.
|
|
||||||
"""
|
|
||||||
if readout_name == "same_time_group_end":
|
|
||||||
readout = build_readout("same_time_group_end",
|
readout = build_readout("same_time_group_end",
|
||||||
reduce=readout_reduce).to(device)
|
reduce=readout_reduce).to(device)
|
||||||
else:
|
elif model_target_mode == "next_token":
|
||||||
readout = build_readout(readout_name).to(device)
|
readout = build_readout(readout_name).to(device)
|
||||||
readout.eval()
|
if readout is not None:
|
||||||
|
readout.eval()
|
||||||
|
|
||||||
hidden_parts: List[np.ndarray] = []
|
hidden_parts: List[np.ndarray] = []
|
||||||
arrays: Dict[str, List[np.ndarray]] = {
|
arrays: Dict[str, List[np.ndarray]] = {
|
||||||
@@ -501,25 +519,55 @@ def infer_readout_hidden(
|
|||||||
if autocast_enabled else contextlib.nullcontext()
|
if autocast_enabled else contextlib.nullcontext()
|
||||||
)
|
)
|
||||||
with amp_context:
|
with amp_context:
|
||||||
hidden = model(
|
if model_target_mode == "all_future":
|
||||||
event_seq=event_seq,
|
batch_size, seq_len = event_seq.shape
|
||||||
time_seq=time_seq,
|
hidden = torch.zeros(
|
||||||
sex=batch_dev["sex"],
|
batch_size,
|
||||||
padding_mask=padding_mask,
|
seq_len,
|
||||||
other_type=batch_dev["other_type"],
|
model.n_embd,
|
||||||
other_value=batch_dev["other_value"],
|
device=event_seq.device,
|
||||||
other_value_kind=batch_dev["other_value_kind"],
|
dtype=torch.float32,
|
||||||
other_time=batch_dev["other_time"],
|
)
|
||||||
target_mode="next_token",
|
for pos in range(seq_len):
|
||||||
)
|
active = padding_mask[:, pos].bool()
|
||||||
ro = readout(
|
if not active.any():
|
||||||
hidden=hidden,
|
continue
|
||||||
time_seq=time_seq,
|
hidden_pos = model(
|
||||||
padding_mask=padding_mask,
|
event_seq=event_seq[active],
|
||||||
readout_mask=batch_dev["readout_mask"],
|
time_seq=time_seq[active],
|
||||||
)
|
sex=batch_dev["sex"][active],
|
||||||
|
padding_mask=padding_mask[active],
|
||||||
|
t_query=time_seq[active, pos],
|
||||||
|
other_type=batch_dev["other_type"][active],
|
||||||
|
other_value=batch_dev["other_value"][active],
|
||||||
|
other_value_kind=batch_dev["other_value_kind"][active],
|
||||||
|
other_time=batch_dev["other_time"][active],
|
||||||
|
target_mode="all_future",
|
||||||
|
)
|
||||||
|
hidden[active, pos, :] = hidden_pos.float()
|
||||||
|
readout_mask_np = batch["padding_mask"].cpu().numpy()
|
||||||
|
else:
|
||||||
|
hidden_raw = model(
|
||||||
|
event_seq=event_seq,
|
||||||
|
time_seq=time_seq,
|
||||||
|
sex=batch_dev["sex"],
|
||||||
|
padding_mask=padding_mask,
|
||||||
|
other_type=batch_dev["other_type"],
|
||||||
|
other_value=batch_dev["other_value"],
|
||||||
|
other_value_kind=batch_dev["other_value_kind"],
|
||||||
|
other_time=batch_dev["other_time"],
|
||||||
|
target_mode="next_token",
|
||||||
|
)
|
||||||
|
ro = readout(
|
||||||
|
hidden=hidden_raw,
|
||||||
|
time_seq=time_seq,
|
||||||
|
padding_mask=padding_mask,
|
||||||
|
readout_mask=batch_dev["readout_mask"],
|
||||||
|
)
|
||||||
|
hidden = ro.hidden
|
||||||
|
readout_mask_np = ro.readout_mask.detach().cpu().numpy()
|
||||||
|
|
||||||
h = ro.hidden.detach().cpu().numpy().astype(out_dtype, copy=False)
|
h = hidden.detach().cpu().numpy().astype(out_dtype, copy=False)
|
||||||
hidden_parts.append(h)
|
hidden_parts.append(h)
|
||||||
max_len = max(max_len, h.shape[1])
|
max_len = max(max_len, h.shape[1])
|
||||||
|
|
||||||
@@ -529,7 +577,7 @@ def infer_readout_hidden(
|
|||||||
batch[k].cpu().numpy().astype(np.int8, copy=False))
|
batch[k].cpu().numpy().astype(np.int8, copy=False))
|
||||||
else:
|
else:
|
||||||
arrays[k].append(batch[k].cpu().numpy())
|
arrays[k].append(batch[k].cpu().numpy())
|
||||||
arrays["readout_mask"][-1] = ro.readout_mask.detach().cpu().numpy()
|
arrays["readout_mask"][-1] = readout_mask_np
|
||||||
|
|
||||||
def pad_3d(parts: List[np.ndarray], fill: float = 0.0) -> np.ndarray:
|
def pad_3d(parts: List[np.ndarray], fill: float = 0.0) -> np.ndarray:
|
||||||
out = np.full(
|
out = np.full(
|
||||||
@@ -999,6 +1047,7 @@ def evaluate_auc_pipeline(
|
|||||||
age_groups: np.ndarray,
|
age_groups: np.ndarray,
|
||||||
offsets: Sequence[float],
|
offsets: Sequence[float],
|
||||||
device: torch.device,
|
device: torch.device,
|
||||||
|
model_target_mode: str,
|
||||||
readout_name: str,
|
readout_name: str,
|
||||||
readout_reduce: str,
|
readout_reduce: str,
|
||||||
num_workers_auc: int,
|
num_workers_auc: int,
|
||||||
@@ -1058,6 +1107,7 @@ def evaluate_auc_pipeline(
|
|||||||
model=model,
|
model=model,
|
||||||
loader=loader,
|
loader=loader,
|
||||||
device=device,
|
device=device,
|
||||||
|
model_target_mode=model_target_mode,
|
||||||
readout_name=readout_name,
|
readout_name=readout_name,
|
||||||
readout_reduce=readout_reduce,
|
readout_reduce=readout_reduce,
|
||||||
use_amp=use_amp,
|
use_amp=use_amp,
|
||||||
@@ -1257,6 +1307,12 @@ def main() -> None:
|
|||||||
include_no_event = cfg.get("include_no_event_in_uts_target", False)
|
include_no_event = cfg.get("include_no_event_in_uts_target", False)
|
||||||
|
|
||||||
target_mode = cfg.get("target_mode", "uts")
|
target_mode = cfg.get("target_mode", "uts")
|
||||||
|
model_target_mode = str(cfg.get("model_target_mode", "next_token")).lower()
|
||||||
|
if model_target_mode not in {"next_token", "all_future"}:
|
||||||
|
raise ValueError(
|
||||||
|
"train_config.json model_target_mode must be next_token or all_future, "
|
||||||
|
f"got {model_target_mode!r}"
|
||||||
|
)
|
||||||
dist_mode_cfg = cfg.get("dist_mode", "exponential")
|
dist_mode_cfg = cfg.get("dist_mode", "exponential")
|
||||||
readout_name = cfg.get(
|
readout_name = cfg.get(
|
||||||
"readout_name", "same_time_group_end" if target_mode == "uts" else "token")
|
"readout_name", "same_time_group_end" if target_mode == "uts" else "token")
|
||||||
@@ -1298,7 +1354,13 @@ def main() -> None:
|
|||||||
dist_mode = resolve_dist_mode_for_checkpoint(dist_mode_cfg, state_dict)
|
dist_mode = resolve_dist_mode_for_checkpoint(dist_mode_cfg, state_dict)
|
||||||
cfg = dict(cfg)
|
cfg = dict(cfg)
|
||||||
cfg["dist_mode"] = dist_mode
|
cfg["dist_mode"] = dist_mode
|
||||||
|
cfg["model_target_mode"] = model_target_mode
|
||||||
print(f"Resolved dist_mode for evaluation: {dist_mode}")
|
print(f"Resolved dist_mode for evaluation: {dist_mode}")
|
||||||
|
print(f"Model target mode for AUC: {model_target_mode}")
|
||||||
|
print(
|
||||||
|
"AUC score semantics: evaluate_auc.py uses disease-specific eta/logit scores; "
|
||||||
|
"dist_mode affects model loading but is not converted to horizon-specific risk probability."
|
||||||
|
)
|
||||||
|
|
||||||
model = build_model_from_dataset(args, cfg, dataset).to(device)
|
model = build_model_from_dataset(args, cfg, dataset).to(device)
|
||||||
load_model_state(model, str(model_ckpt_path),
|
load_model_state(model, str(model_ckpt_path),
|
||||||
@@ -1335,6 +1397,7 @@ def main() -> None:
|
|||||||
age_groups=age_groups,
|
age_groups=age_groups,
|
||||||
offsets=auc_offsets,
|
offsets=auc_offsets,
|
||||||
device=device,
|
device=device,
|
||||||
|
model_target_mode=model_target_mode,
|
||||||
readout_name=readout_name,
|
readout_name=readout_name,
|
||||||
readout_reduce=readout_reduce,
|
readout_reduce=readout_reduce,
|
||||||
num_workers_auc=int(cfg_get(args, cfg, "num_workers_auc", max(
|
num_workers_auc=int(cfg_get(args, cfg, "num_workers_auc", max(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Evaluate landmark fixed-horizon incident disease AUC for DeepHealth.
|
"""Evaluate landmark fixed-horizon incident disease AUC for DeepHealth.
|
||||||
|
|
||||||
This script is intentionally strict and supports only:
|
This script supports DeepHealth fixed-horizon risk scores for exponential,
|
||||||
DeepHealth + exponential distribution + no_event imputation.
|
Weibull, and mixed all-future distributions.
|
||||||
|
|
||||||
Landmark querying is implemented by inserting a <NO_EVENT> token at landmark age.
|
Landmark querying depends on the model target mode saved in train_config.json:
|
||||||
No t_query interface is used.
|
- next_token: insert a <NO_EVENT> token at landmark age and read it out;
|
||||||
|
- all_future: pass landmark age directly as t_query.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import torch
|
import torch
|
||||||
|
import torch.nn.functional as F
|
||||||
from torch.nn.utils.rnn import pad_sequence
|
from torch.nn.utils.rnn import pad_sequence
|
||||||
from torch.utils.data import DataLoader, Dataset
|
from torch.utils.data import DataLoader, Dataset
|
||||||
from tqdm.auto import tqdm
|
from tqdm.auto import tqdm
|
||||||
@@ -140,12 +142,36 @@ def resolve_dist_mode_for_checkpoint(cfg_dist_mode: str, state_dict: Dict[str, A
|
|||||||
mode = str(cfg_dist_mode).lower()
|
mode = str(cfg_dist_mode).lower()
|
||||||
has_rho_head = any(str(k).startswith("rho_head.")
|
has_rho_head = any(str(k).startswith("rho_head.")
|
||||||
for k in state_dict.keys())
|
for k in state_dict.keys())
|
||||||
|
has_rho_death_head = any(str(k).startswith("rho_death_head.")
|
||||||
|
for k in state_dict.keys())
|
||||||
if has_rho_head:
|
if has_rho_head:
|
||||||
|
if mode != "weibull":
|
||||||
|
print(
|
||||||
|
"[WARN] Checkpoint contains rho_head weights; overriding dist_mode to 'weibull' for evaluation.")
|
||||||
return "weibull"
|
return "weibull"
|
||||||
return mode if mode in {"exponential", "weibull"} else "exponential"
|
if has_rho_death_head:
|
||||||
|
if mode != "mixed":
|
||||||
|
print(
|
||||||
|
"[WARN] Checkpoint contains rho_death_head weights; overriding dist_mode to 'mixed' for evaluation.")
|
||||||
|
return "mixed"
|
||||||
|
if mode == "weibull":
|
||||||
|
print(
|
||||||
|
"[WARN] dist_mode is 'weibull' but checkpoint has no rho_head weights; overriding dist_mode to 'exponential'.")
|
||||||
|
return "exponential"
|
||||||
|
if mode == "mixed":
|
||||||
|
print(
|
||||||
|
"[WARN] dist_mode is 'mixed' but checkpoint has no rho_death_head weights; overriding dist_mode to 'exponential'.")
|
||||||
|
return "exponential"
|
||||||
|
return mode if mode in {"exponential", "weibull", "mixed"} else "exponential"
|
||||||
|
|
||||||
|
|
||||||
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
|
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
|
||||||
|
model_target_mode = str(cfg_get(
|
||||||
|
args, cfg, "model_target_mode", "next_token")).lower()
|
||||||
|
if model_target_mode not in {"next_token", "all_future"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"model_target_mode must be next_token or all_future, got {model_target_mode!r}"
|
||||||
|
)
|
||||||
return DeepHealth(
|
return DeepHealth(
|
||||||
vocab_size=dataset.vocab_size,
|
vocab_size=dataset.vocab_size,
|
||||||
n_embd=int(cfg_get(args, cfg, "n_embd", 120)),
|
n_embd=int(cfg_get(args, cfg, "n_embd", 120)),
|
||||||
@@ -157,7 +183,7 @@ def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], data
|
|||||||
n_categories=dataset.n_categories,
|
n_categories=dataset.n_categories,
|
||||||
cont_type_ids=dataset.cont_type_ids,
|
cont_type_ids=dataset.cont_type_ids,
|
||||||
n_bins=int(cfg_get(args, cfg, "n_bins", 16)),
|
n_bins=int(cfg_get(args, cfg, "n_bins", 16)),
|
||||||
target_mode="next_token",
|
target_mode=model_target_mode,
|
||||||
time_mode=str(cfg_get(args, cfg, "time_mode", "relative")),
|
time_mode=str(cfg_get(args, cfg, "time_mode", "relative")),
|
||||||
dist_mode=str(cfg_get(args, cfg, "dist_mode", "exponential")),
|
dist_mode=str(cfg_get(args, cfg, "dist_mode", "exponential")),
|
||||||
dropout=float(cfg_get(args, cfg, "dropout", 0.0)),
|
dropout=float(cfg_get(args, cfg, "dropout", 0.0)),
|
||||||
@@ -489,6 +515,7 @@ class LandmarkDataset(Dataset):
|
|||||||
subset_indices: np.ndarray,
|
subset_indices: np.ndarray,
|
||||||
landmark_ages: np.ndarray,
|
landmark_ages: np.ndarray,
|
||||||
attn_mask_mode: str,
|
attn_mask_mode: str,
|
||||||
|
model_target_mode: str,
|
||||||
min_history_events: int,
|
min_history_events: int,
|
||||||
first_occurrence_by_token: Dict[int, Tuple[np.ndarray, np.ndarray]],
|
first_occurrence_by_token: Dict[int, Tuple[np.ndarray, np.ndarray]],
|
||||||
death_token_ids: Sequence[int],
|
death_token_ids: Sequence[int],
|
||||||
@@ -497,6 +524,12 @@ class LandmarkDataset(Dataset):
|
|||||||
self.subset_indices = np.asarray(subset_indices, dtype=np.int64)
|
self.subset_indices = np.asarray(subset_indices, dtype=np.int64)
|
||||||
self.landmark_ages = np.asarray(landmark_ages, dtype=np.float32)
|
self.landmark_ages = np.asarray(landmark_ages, dtype=np.float32)
|
||||||
self.attn_mask_mode = str(attn_mask_mode).lower()
|
self.attn_mask_mode = str(attn_mask_mode).lower()
|
||||||
|
self.model_target_mode = str(model_target_mode).lower()
|
||||||
|
if self.model_target_mode not in {"next_token", "all_future"}:
|
||||||
|
raise ValueError(
|
||||||
|
"model_target_mode must be next_token or all_future, got "
|
||||||
|
f"{self.model_target_mode!r}"
|
||||||
|
)
|
||||||
self.min_history_events = int(min_history_events)
|
self.min_history_events = int(min_history_events)
|
||||||
|
|
||||||
self.first_occurrence_by_token = first_occurrence_by_token
|
self.first_occurrence_by_token = first_occurrence_by_token
|
||||||
@@ -555,25 +588,33 @@ class LandmarkDataset(Dataset):
|
|||||||
if valid_history_mask.sum() < self.min_history_events:
|
if valid_history_mask.sum() < self.min_history_events:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
event_seq_landmark = np.concatenate(
|
if self.model_target_mode == "next_token":
|
||||||
[
|
event_seq_landmark = np.concatenate(
|
||||||
prefix_events.astype(np.int64, copy=False),
|
[
|
||||||
np.array([NO_EVENT_IDX], dtype=np.int64),
|
prefix_events.astype(np.int64, copy=False),
|
||||||
]
|
np.array([NO_EVENT_IDX], dtype=np.int64),
|
||||||
)
|
]
|
||||||
time_seq_landmark = np.concatenate(
|
|
||||||
[
|
|
||||||
prefix_times.astype(np.float32, copy=False),
|
|
||||||
np.array([np.float32(landmark_age)], dtype=np.float32),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if self.attn_mask_mode in _TARGET_AWARE_MODES:
|
|
||||||
time_seq_landmark[-1] = np.nextafter(
|
|
||||||
np.float32(landmark_age), np.float32(np.inf), dtype=np.float32
|
|
||||||
)
|
)
|
||||||
|
time_seq_landmark = np.concatenate(
|
||||||
readout_mask = np.zeros(len(event_seq_landmark), dtype=bool)
|
[
|
||||||
readout_mask[-1] = True
|
prefix_times.astype(np.float32, copy=False),
|
||||||
|
np.array([np.float32(landmark_age)], dtype=np.float32),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if self.attn_mask_mode in _TARGET_AWARE_MODES:
|
||||||
|
time_seq_landmark[-1] = np.nextafter(
|
||||||
|
np.float32(landmark_age), np.float32(np.inf), dtype=np.float32
|
||||||
|
)
|
||||||
|
landmark_pos = int(len(event_seq_landmark) - 1)
|
||||||
|
readout_mask = np.zeros(len(event_seq_landmark), dtype=bool)
|
||||||
|
readout_mask[-1] = True
|
||||||
|
else:
|
||||||
|
event_seq_landmark = prefix_events.astype(
|
||||||
|
np.int64, copy=False)
|
||||||
|
time_seq_landmark = prefix_times.astype(
|
||||||
|
np.float32, copy=False)
|
||||||
|
landmark_pos = int(len(event_seq_landmark) - 1)
|
||||||
|
readout_mask = np.zeros(len(event_seq_landmark), dtype=bool)
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
@@ -583,7 +624,8 @@ class LandmarkDataset(Dataset):
|
|||||||
"landmark_age": np.float32(landmark_age),
|
"landmark_age": np.float32(landmark_age),
|
||||||
"followup_end_time": np.float32(followup_end),
|
"followup_end_time": np.float32(followup_end),
|
||||||
"death_time": np.float32(self.patient_death_time[patient_id]),
|
"death_time": np.float32(self.patient_death_time[patient_id]),
|
||||||
"landmark_pos": int(len(event_seq_landmark) - 1),
|
"landmark_pos": landmark_pos,
|
||||||
|
"t_query": np.float32(landmark_age),
|
||||||
"event_seq": event_seq_landmark,
|
"event_seq": event_seq_landmark,
|
||||||
"time_seq": time_seq_landmark,
|
"time_seq": time_seq_landmark,
|
||||||
"readout_mask": readout_mask,
|
"readout_mask": readout_mask,
|
||||||
@@ -616,6 +658,7 @@ class LandmarkDataset(Dataset):
|
|||||||
"other_value_kind": torch.from_numpy(s["other_value_kind"]).long(),
|
"other_value_kind": torch.from_numpy(s["other_value_kind"]).long(),
|
||||||
"other_time": torch.from_numpy(s["other_time"]).float(),
|
"other_time": torch.from_numpy(s["other_time"]).float(),
|
||||||
"landmark_pos": torch.tensor(s["landmark_pos"], dtype=torch.long),
|
"landmark_pos": torch.tensor(s["landmark_pos"], dtype=torch.long),
|
||||||
|
"t_query": torch.tensor(float(s["t_query"]), dtype=torch.float32),
|
||||||
"patient_id": torch.tensor(s["patient_id"], dtype=torch.long),
|
"patient_id": torch.tensor(s["patient_id"], dtype=torch.long),
|
||||||
"landmark_age": torch.tensor(float(s["landmark_age"]), dtype=torch.float32),
|
"landmark_age": torch.tensor(float(s["landmark_age"]), dtype=torch.float32),
|
||||||
"followup_end_time": torch.tensor(float(s["followup_end_time"]), dtype=torch.float32),
|
"followup_end_time": torch.tensor(float(s["followup_end_time"]), dtype=torch.float32),
|
||||||
@@ -650,6 +693,7 @@ def collate_landmark_fn(batch: List[Dict[str, torch.Tensor]]) -> Dict[str, torch
|
|||||||
"other_value_kind": other_value_kind,
|
"other_value_kind": other_value_kind,
|
||||||
"other_time": other_time,
|
"other_time": other_time,
|
||||||
"landmark_pos": torch.stack([x["landmark_pos"] for x in batch]),
|
"landmark_pos": torch.stack([x["landmark_pos"] for x in batch]),
|
||||||
|
"t_query": torch.stack([x["t_query"] for x in batch]),
|
||||||
"patient_id": torch.stack([x["patient_id"] for x in batch]),
|
"patient_id": torch.stack([x["patient_id"] for x in batch]),
|
||||||
"landmark_age": torch.stack([x["landmark_age"] for x in batch]),
|
"landmark_age": torch.stack([x["landmark_age"] for x in batch]),
|
||||||
"followup_end_time": torch.stack([x["followup_end_time"] for x in batch]),
|
"followup_end_time": torch.stack([x["followup_end_time"] for x in batch]),
|
||||||
@@ -672,17 +716,26 @@ def infer_landmark_hidden(
|
|||||||
model: DeepHealth,
|
model: DeepHealth,
|
||||||
loader: DataLoader,
|
loader: DataLoader,
|
||||||
device: torch.device,
|
device: torch.device,
|
||||||
|
model_target_mode: str,
|
||||||
readout_name: str,
|
readout_name: str,
|
||||||
readout_reduce: str,
|
readout_reduce: str,
|
||||||
use_amp: bool,
|
use_amp: bool,
|
||||||
hidden_cache_dtype: str,
|
hidden_cache_dtype: str,
|
||||||
) -> Tuple[np.ndarray, Dict[str, np.ndarray]]:
|
) -> Tuple[np.ndarray, Dict[str, np.ndarray]]:
|
||||||
if readout_name == "same_time_group_end":
|
model_target_mode = str(model_target_mode).lower()
|
||||||
|
if model_target_mode not in {"next_token", "all_future"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"model_target_mode must be next_token or all_future, got {model_target_mode!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
readout = None
|
||||||
|
if model_target_mode == "next_token" and readout_name == "same_time_group_end":
|
||||||
readout = build_readout("same_time_group_end",
|
readout = build_readout("same_time_group_end",
|
||||||
reduce=readout_reduce).to(device)
|
reduce=readout_reduce).to(device)
|
||||||
else:
|
elif model_target_mode == "next_token":
|
||||||
readout = build_readout(readout_name).to(device)
|
readout = build_readout(readout_name).to(device)
|
||||||
readout.eval()
|
if readout is not None:
|
||||||
|
readout.eval()
|
||||||
|
|
||||||
hidden_parts: List[np.ndarray] = []
|
hidden_parts: List[np.ndarray] = []
|
||||||
arrays = {
|
arrays = {
|
||||||
@@ -709,29 +762,43 @@ def infer_landmark_hidden(
|
|||||||
else contextlib.nullcontext()
|
else contextlib.nullcontext()
|
||||||
)
|
)
|
||||||
with amp_ctx:
|
with amp_ctx:
|
||||||
hidden = model(
|
if model_target_mode == "all_future":
|
||||||
event_seq=batch_dev["event_seq"],
|
landmark_hidden = model(
|
||||||
time_seq=batch_dev["time_seq"],
|
event_seq=batch_dev["event_seq"],
|
||||||
sex=batch_dev["sex"],
|
time_seq=batch_dev["time_seq"],
|
||||||
padding_mask=batch_dev["padding_mask"],
|
sex=batch_dev["sex"],
|
||||||
other_type=batch_dev["other_type"],
|
padding_mask=batch_dev["padding_mask"],
|
||||||
other_value=batch_dev["other_value"],
|
t_query=batch_dev["t_query"],
|
||||||
other_value_kind=batch_dev["other_value_kind"],
|
other_type=batch_dev["other_type"],
|
||||||
other_time=batch_dev["other_time"],
|
other_value=batch_dev["other_value"],
|
||||||
target_mode="next_token",
|
other_value_kind=batch_dev["other_value_kind"],
|
||||||
)
|
other_time=batch_dev["other_time"],
|
||||||
readout_out = readout(
|
target_mode="all_future",
|
||||||
hidden=hidden,
|
)
|
||||||
time_seq=batch_dev["time_seq"],
|
else:
|
||||||
padding_mask=batch_dev["padding_mask"],
|
hidden = model(
|
||||||
readout_mask=batch_dev["readout_mask"],
|
event_seq=batch_dev["event_seq"],
|
||||||
)
|
time_seq=batch_dev["time_seq"],
|
||||||
|
sex=batch_dev["sex"],
|
||||||
landmark_hidden = readout_out.hidden.gather(
|
padding_mask=batch_dev["padding_mask"],
|
||||||
1,
|
other_type=batch_dev["other_type"],
|
||||||
batch_dev["landmark_pos"].long()[:, None, None].expand(-1,
|
other_value=batch_dev["other_value"],
|
||||||
1, readout_out.hidden.shape[-1]),
|
other_value_kind=batch_dev["other_value_kind"],
|
||||||
).squeeze(1)
|
other_time=batch_dev["other_time"],
|
||||||
|
target_mode="next_token",
|
||||||
|
)
|
||||||
|
readout_out = readout(
|
||||||
|
hidden=hidden,
|
||||||
|
time_seq=batch_dev["time_seq"],
|
||||||
|
padding_mask=batch_dev["padding_mask"],
|
||||||
|
readout_mask=batch_dev["readout_mask"],
|
||||||
|
)
|
||||||
|
landmark_hidden = readout_out.hidden.gather(
|
||||||
|
1,
|
||||||
|
batch_dev["landmark_pos"].long()[:, None, None].expand(
|
||||||
|
-1, 1, readout_out.hidden.shape[-1]
|
||||||
|
),
|
||||||
|
).squeeze(1)
|
||||||
|
|
||||||
hidden_parts.append(landmark_hidden.detach(
|
hidden_parts.append(landmark_hidden.detach(
|
||||||
).cpu().numpy().astype(out_dtype, copy=False))
|
).cpu().numpy().astype(out_dtype, copy=False))
|
||||||
@@ -754,33 +821,76 @@ def infer_landmark_hidden(
|
|||||||
|
|
||||||
|
|
||||||
@torch.inference_mode()
|
@torch.inference_mode()
|
||||||
def project_logits_chunk(
|
def project_distribution_chunk(
|
||||||
model: DeepHealth,
|
model: DeepHealth,
|
||||||
hidden_all: np.ndarray,
|
hidden_all: np.ndarray,
|
||||||
disease_ids: Sequence[int],
|
disease_ids: Sequence[int],
|
||||||
|
dist_mode: str,
|
||||||
device: torch.device,
|
device: torch.device,
|
||||||
logit_batch_size: int,
|
logit_batch_size: int,
|
||||||
use_amp: bool,
|
use_amp: bool,
|
||||||
) -> np.ndarray:
|
) -> Tuple[np.ndarray, Optional[np.ndarray]]:
|
||||||
n = int(hidden_all.shape[0])
|
n = int(hidden_all.shape[0])
|
||||||
logit_batch_size = max(1, int(logit_batch_size))
|
logit_batch_size = max(1, int(logit_batch_size))
|
||||||
disease_ids = [int(x) for x in disease_ids]
|
disease_ids = [int(x) for x in disease_ids]
|
||||||
|
dist_mode = str(dist_mode).lower()
|
||||||
|
|
||||||
compute_dtype = torch.float16 if (
|
compute_dtype = torch.float16 if (
|
||||||
device.type == "cuda" and use_amp) else torch.float32
|
device.type == "cuda" and use_amp) else torch.float32
|
||||||
weight = model.risk_head.weight[disease_ids].detach().to(
|
weight = model.risk_head.weight[disease_ids].detach().to(
|
||||||
device=device, dtype=compute_dtype)
|
device=device, dtype=compute_dtype)
|
||||||
|
bias = model.risk_head.bias[disease_ids].detach().to(
|
||||||
|
device=device, dtype=compute_dtype)
|
||||||
|
rho_weight = None
|
||||||
|
rho_bias = None
|
||||||
|
death_rho_weight = None
|
||||||
|
death_rho_bias = None
|
||||||
|
mixed_death_cols: List[int] = []
|
||||||
|
death_idx = int(getattr(model, "death_idx", getattr(model, "vocab_size", 0) - 1))
|
||||||
|
|
||||||
|
if dist_mode == "weibull":
|
||||||
|
rho_weight = model.rho_head.weight[disease_ids].detach().to(
|
||||||
|
device=device, dtype=compute_dtype)
|
||||||
|
rho_bias = model.rho_head.bias[disease_ids].detach().to(
|
||||||
|
device=device, dtype=compute_dtype)
|
||||||
|
elif dist_mode == "mixed":
|
||||||
|
mixed_death_cols = [j for j, token in enumerate(disease_ids)
|
||||||
|
if int(token) == death_idx]
|
||||||
|
if mixed_death_cols:
|
||||||
|
death_rho_weight = model.rho_death_head.weight.detach().to(
|
||||||
|
device=device, dtype=compute_dtype)
|
||||||
|
death_rho_bias = model.rho_death_head.bias.detach().to(
|
||||||
|
device=device, dtype=compute_dtype)
|
||||||
|
|
||||||
out_parts: List[np.ndarray] = []
|
out_parts: List[np.ndarray] = []
|
||||||
|
rho_parts: List[np.ndarray] = []
|
||||||
for start in tqdm(range(0, n, logit_batch_size), desc="Risk projection", leave=False, dynamic_ncols=True):
|
for start in tqdm(range(0, n, logit_batch_size), desc="Risk projection", leave=False, dynamic_ncols=True):
|
||||||
end = min(start + logit_batch_size, n)
|
end = min(start + logit_batch_size, n)
|
||||||
h = torch.from_numpy(hidden_all[start:end]).to(
|
h = torch.from_numpy(hidden_all[start:end]).to(
|
||||||
device=device, dtype=compute_dtype, non_blocking=True)
|
device=device, dtype=compute_dtype, non_blocking=True)
|
||||||
logits = torch.matmul(h, weight.t())
|
logits = torch.matmul(h, weight.t()) + bias
|
||||||
|
rho = None
|
||||||
|
if dist_mode == "weibull":
|
||||||
|
assert rho_weight is not None and rho_bias is not None
|
||||||
|
rho = F.softplus(torch.matmul(h, rho_weight.t()) + rho_bias) + 1e-6
|
||||||
|
elif dist_mode == "mixed" and mixed_death_cols:
|
||||||
|
assert death_rho_weight is not None and death_rho_bias is not None
|
||||||
|
rho = torch.ones_like(logits)
|
||||||
|
death_rho = F.softplus(
|
||||||
|
torch.matmul(h, death_rho_weight.t()).squeeze(-1) + death_rho_bias.squeeze(0)
|
||||||
|
) + 1e-6
|
||||||
|
for col in mixed_death_cols:
|
||||||
|
rho[:, int(col)] = death_rho
|
||||||
|
|
||||||
out_parts.append(logits.float().cpu(
|
out_parts.append(logits.float().cpu(
|
||||||
).numpy().astype(np.float32, copy=False))
|
).numpy().astype(np.float32, copy=False))
|
||||||
del h, logits
|
if rho is not None:
|
||||||
return np.concatenate(out_parts, axis=0)
|
rho_parts.append(rho.float().cpu(
|
||||||
|
).numpy().astype(np.float32, copy=False))
|
||||||
|
del h, logits, rho
|
||||||
|
logits_all = np.concatenate(out_parts, axis=0)
|
||||||
|
rho_all = np.concatenate(rho_parts, axis=0) if rho_parts else None
|
||||||
|
return logits_all, rho_all
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -794,6 +904,7 @@ _WORKER: Dict[str, Any] = {}
|
|||||||
def _init_worker(
|
def _init_worker(
|
||||||
disease_ids: np.ndarray,
|
disease_ids: np.ndarray,
|
||||||
score_chunk: np.ndarray,
|
score_chunk: np.ndarray,
|
||||||
|
rho_chunk: Optional[np.ndarray],
|
||||||
row_patient_id: np.ndarray,
|
row_patient_id: np.ndarray,
|
||||||
row_sex: np.ndarray,
|
row_sex: np.ndarray,
|
||||||
row_landmark_age: np.ndarray,
|
row_landmark_age: np.ndarray,
|
||||||
@@ -805,6 +916,8 @@ def _init_worker(
|
|||||||
min_cases: int,
|
min_cases: int,
|
||||||
exclude_death_competing: bool,
|
exclude_death_competing: bool,
|
||||||
death_token_ids: np.ndarray,
|
death_token_ids: np.ndarray,
|
||||||
|
dist_mode: str,
|
||||||
|
model_death_idx: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
||||||
os.environ.setdefault("MKL_NUM_THREADS", "1")
|
os.environ.setdefault("MKL_NUM_THREADS", "1")
|
||||||
@@ -816,6 +929,7 @@ def _init_worker(
|
|||||||
{
|
{
|
||||||
"disease_ids": np.asarray(disease_ids, dtype=np.int64),
|
"disease_ids": np.asarray(disease_ids, dtype=np.int64),
|
||||||
"score_chunk": np.asarray(score_chunk, dtype=np.float32),
|
"score_chunk": np.asarray(score_chunk, dtype=np.float32),
|
||||||
|
"rho_chunk": None if rho_chunk is None else np.asarray(rho_chunk, dtype=np.float32),
|
||||||
"row_patient_id": np.asarray(row_patient_id, dtype=np.int32),
|
"row_patient_id": np.asarray(row_patient_id, dtype=np.int32),
|
||||||
"row_sex": np.asarray(row_sex, dtype=np.int8),
|
"row_sex": np.asarray(row_sex, dtype=np.int8),
|
||||||
"row_landmark_age": np.asarray(row_landmark_age, dtype=np.float32),
|
"row_landmark_age": np.asarray(row_landmark_age, dtype=np.float32),
|
||||||
@@ -827,6 +941,8 @@ def _init_worker(
|
|||||||
"min_cases": int(min_cases),
|
"min_cases": int(min_cases),
|
||||||
"exclude_death_competing": bool(exclude_death_competing),
|
"exclude_death_competing": bool(exclude_death_competing),
|
||||||
"death_token_ids": set(int(x) for x in np.asarray(death_token_ids, dtype=np.int64).tolist()),
|
"death_token_ids": set(int(x) for x in np.asarray(death_token_ids, dtype=np.int64).tolist()),
|
||||||
|
"dist_mode": str(dist_mode).lower(),
|
||||||
|
"model_death_idx": int(model_death_idx),
|
||||||
"first_time_cache": {},
|
"first_time_cache": {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -846,11 +962,30 @@ def _first_time_by_patient(token: int) -> np.ndarray:
|
|||||||
return arr
|
return arr
|
||||||
|
|
||||||
|
|
||||||
def _score_to_probability(logits: np.ndarray, score_mode: str, horizon: float) -> np.ndarray:
|
def _score_to_probability(
|
||||||
|
logits: np.ndarray,
|
||||||
|
rho: Optional[np.ndarray],
|
||||||
|
score_mode: str,
|
||||||
|
horizon: float,
|
||||||
|
dist_mode: str,
|
||||||
|
token: int,
|
||||||
|
death_idx: int,
|
||||||
|
) -> np.ndarray:
|
||||||
if score_mode == "eta":
|
if score_mode == "eta":
|
||||||
return logits.astype(np.float64, copy=False)
|
return logits.astype(np.float64, copy=False)
|
||||||
rate = np.log1p(np.exp(-np.abs(logits))) + np.maximum(logits, 0.0)
|
rate = np.log1p(np.exp(-np.abs(logits))) + np.maximum(logits, 0.0)
|
||||||
rate = rate + np.float32(1e-8)
|
rate = rate + np.float32(1e-8)
|
||||||
|
dist_mode = str(dist_mode).lower()
|
||||||
|
if dist_mode == "weibull":
|
||||||
|
if rho is None:
|
||||||
|
raise RuntimeError("Weibull risk scoring requires rho parameters.")
|
||||||
|
exposure = np.power(np.float32(horizon), rho.astype(np.float32, copy=False))
|
||||||
|
return (-np.expm1(-rate * exposure)).astype(np.float64, copy=False)
|
||||||
|
if dist_mode == "mixed" and int(token) == int(death_idx):
|
||||||
|
if rho is None:
|
||||||
|
raise RuntimeError("Mixed death risk scoring requires death rho parameters.")
|
||||||
|
exposure = np.power(np.float32(horizon), rho.astype(np.float32, copy=False))
|
||||||
|
return (-np.expm1(-rate * exposure)).astype(np.float64, copy=False)
|
||||||
return (-np.expm1(-rate * np.float32(horizon))).astype(np.float64, copy=False)
|
return (-np.expm1(-rate * np.float32(horizon))).astype(np.float64, copy=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -864,6 +999,10 @@ def _eval_token(task: Tuple[int, int, str]) -> List[Dict[str, Any]]:
|
|||||||
row_followup_end = _WORKER["row_followup_end"]
|
row_followup_end = _WORKER["row_followup_end"]
|
||||||
row_death_time = _WORKER["row_death_time"]
|
row_death_time = _WORKER["row_death_time"]
|
||||||
logits_token = _WORKER["score_chunk"][:, int(j)]
|
logits_token = _WORKER["score_chunk"][:, int(j)]
|
||||||
|
rho_chunk = _WORKER["rho_chunk"]
|
||||||
|
rho_token = None if rho_chunk is None else rho_chunk[:, int(j)]
|
||||||
|
dist_mode = _WORKER["dist_mode"]
|
||||||
|
model_death_idx = int(_WORKER["model_death_idx"])
|
||||||
|
|
||||||
first_time_patient = _first_time_by_patient(token)
|
first_time_patient = _first_time_by_patient(token)
|
||||||
is_death_target = token in _WORKER["death_token_ids"]
|
is_death_target = token in _WORKER["death_token_ids"]
|
||||||
@@ -917,9 +1056,23 @@ def _eval_token(task: Tuple[int, int, str]) -> List[Dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
case_scores = _score_to_probability(
|
case_scores = _score_to_probability(
|
||||||
logits_token[idx[case_idx]], score_mode=score_mode, horizon=horizon)
|
logits_token[idx[case_idx]],
|
||||||
|
None if rho_token is None else rho_token[idx[case_idx]],
|
||||||
|
score_mode=score_mode,
|
||||||
|
horizon=horizon,
|
||||||
|
dist_mode=dist_mode,
|
||||||
|
token=token,
|
||||||
|
death_idx=model_death_idx,
|
||||||
|
)
|
||||||
control_scores = _score_to_probability(
|
control_scores = _score_to_probability(
|
||||||
logits_token[idx[control_idx]], score_mode=score_mode, horizon=horizon)
|
logits_token[idx[control_idx]],
|
||||||
|
None if rho_token is None else rho_token[idx[control_idx]],
|
||||||
|
score_mode=score_mode,
|
||||||
|
horizon=horizon,
|
||||||
|
dist_mode=dist_mode,
|
||||||
|
token=token,
|
||||||
|
death_idx=model_death_idx,
|
||||||
|
)
|
||||||
|
|
||||||
auc, auc_var = get_auc_delong_var(case_scores, control_scores)
|
auc, auc_var = get_auc_delong_var(case_scores, control_scores)
|
||||||
if np.isnan(auc) or np.isnan(auc_var):
|
if np.isnan(auc) or np.isnan(auc_var):
|
||||||
@@ -973,6 +1126,7 @@ def evaluate_landmark_auc(
|
|||||||
score_mode: str,
|
score_mode: str,
|
||||||
horizons: np.ndarray,
|
horizons: np.ndarray,
|
||||||
device: torch.device,
|
device: torch.device,
|
||||||
|
model_target_mode: str,
|
||||||
readout_name: str,
|
readout_name: str,
|
||||||
readout_reduce: str,
|
readout_reduce: str,
|
||||||
num_workers_auc: int,
|
num_workers_auc: int,
|
||||||
@@ -990,6 +1144,7 @@ def evaluate_landmark_auc(
|
|||||||
model=model,
|
model=model,
|
||||||
loader=loader,
|
loader=loader,
|
||||||
device=device,
|
device=device,
|
||||||
|
model_target_mode=model_target_mode,
|
||||||
readout_name=readout_name,
|
readout_name=readout_name,
|
||||||
readout_reduce=readout_reduce,
|
readout_reduce=readout_reduce,
|
||||||
use_amp=use_amp,
|
use_amp=use_amp,
|
||||||
@@ -1007,10 +1162,11 @@ def evaluate_landmark_auc(
|
|||||||
all_rows: List[Dict[str, Any]] = []
|
all_rows: List[Dict[str, Any]] = []
|
||||||
for chunk_idx, chunk in enumerate(tqdm(chunks, desc="Disease chunks", dynamic_ncols=True)):
|
for chunk_idx, chunk in enumerate(tqdm(chunks, desc="Disease chunks", dynamic_ncols=True)):
|
||||||
chunk_ids = chunk.tolist()
|
chunk_ids = chunk.tolist()
|
||||||
logits_chunk = project_logits_chunk(
|
logits_chunk, rho_chunk = project_distribution_chunk(
|
||||||
model=model,
|
model=model,
|
||||||
hidden_all=hidden_all,
|
hidden_all=hidden_all,
|
||||||
disease_ids=chunk_ids,
|
disease_ids=chunk_ids,
|
||||||
|
dist_mode=dist_mode,
|
||||||
device=device,
|
device=device,
|
||||||
logit_batch_size=logit_batch_size,
|
logit_batch_size=logit_batch_size,
|
||||||
use_amp=use_amp,
|
use_amp=use_amp,
|
||||||
@@ -1024,6 +1180,7 @@ def evaluate_landmark_auc(
|
|||||||
_init_worker(
|
_init_worker(
|
||||||
disease_ids=np.asarray(chunk_ids, dtype=np.int64),
|
disease_ids=np.asarray(chunk_ids, dtype=np.int64),
|
||||||
score_chunk=logits_chunk,
|
score_chunk=logits_chunk,
|
||||||
|
rho_chunk=rho_chunk,
|
||||||
row_patient_id=row_arrays["patient_id"],
|
row_patient_id=row_arrays["patient_id"],
|
||||||
row_sex=row_arrays["sex"],
|
row_sex=row_arrays["sex"],
|
||||||
row_landmark_age=row_arrays["landmark_age"],
|
row_landmark_age=row_arrays["landmark_age"],
|
||||||
@@ -1036,6 +1193,8 @@ def evaluate_landmark_auc(
|
|||||||
exclude_death_competing=exclude_death_competing,
|
exclude_death_competing=exclude_death_competing,
|
||||||
death_token_ids=np.asarray(
|
death_token_ids=np.asarray(
|
||||||
landmark_dataset.death_token_ids, dtype=np.int64),
|
landmark_dataset.death_token_ids, dtype=np.int64),
|
||||||
|
dist_mode=dist_mode,
|
||||||
|
model_death_idx=int(getattr(model, "death_idx", dataset.vocab_size - 1)),
|
||||||
)
|
)
|
||||||
nested = [_eval_token(t) for t in tqdm(
|
nested = [_eval_token(t) for t in tqdm(
|
||||||
tasks, desc=f"AUC chunk {chunk_idx}", leave=False, dynamic_ncols=True)]
|
tasks, desc=f"AUC chunk {chunk_idx}", leave=False, dynamic_ncols=True)]
|
||||||
@@ -1049,6 +1208,7 @@ def evaluate_landmark_auc(
|
|||||||
initargs=(
|
initargs=(
|
||||||
np.asarray(chunk_ids, dtype=np.int64),
|
np.asarray(chunk_ids, dtype=np.int64),
|
||||||
logits_chunk,
|
logits_chunk,
|
||||||
|
rho_chunk,
|
||||||
row_arrays["patient_id"],
|
row_arrays["patient_id"],
|
||||||
row_arrays["sex"],
|
row_arrays["sex"],
|
||||||
row_arrays["landmark_age"],
|
row_arrays["landmark_age"],
|
||||||
@@ -1061,6 +1221,8 @@ def evaluate_landmark_auc(
|
|||||||
exclude_death_competing,
|
exclude_death_competing,
|
||||||
np.asarray(landmark_dataset.death_token_ids,
|
np.asarray(landmark_dataset.death_token_ids,
|
||||||
dtype=np.int64),
|
dtype=np.int64),
|
||||||
|
dist_mode,
|
||||||
|
int(getattr(model, "death_idx", dataset.vocab_size - 1)),
|
||||||
),
|
),
|
||||||
) as ex:
|
) as ex:
|
||||||
nested = list(
|
nested = list(
|
||||||
@@ -1078,7 +1240,7 @@ def evaluate_landmark_auc(
|
|||||||
r["disease_chunk_idx"] = int(chunk_idx)
|
r["disease_chunk_idx"] = int(chunk_idx)
|
||||||
all_rows.append(r)
|
all_rows.append(r)
|
||||||
|
|
||||||
del logits_chunk
|
del logits_chunk, rho_chunk
|
||||||
|
|
||||||
if not all_rows:
|
if not all_rows:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -1116,6 +1278,7 @@ def evaluate_landmark_auc(
|
|||||||
"model_ckpt_path",
|
"model_ckpt_path",
|
||||||
"config_path",
|
"config_path",
|
||||||
"target_mode",
|
"target_mode",
|
||||||
|
"model_target_mode",
|
||||||
"dist_mode",
|
"dist_mode",
|
||||||
"time_mode",
|
"time_mode",
|
||||||
"attn_mask_mode",
|
"attn_mask_mode",
|
||||||
@@ -1194,6 +1357,12 @@ def main() -> None:
|
|||||||
"include_no_event_in_uts_target", False)
|
"include_no_event_in_uts_target", False)
|
||||||
|
|
||||||
target_mode = cfg.get("target_mode", "uts")
|
target_mode = cfg.get("target_mode", "uts")
|
||||||
|
model_target_mode = str(cfg.get("model_target_mode", "next_token")).lower()
|
||||||
|
if model_target_mode not in {"next_token", "all_future"}:
|
||||||
|
raise ValueError(
|
||||||
|
"train_config.json model_target_mode must be next_token or all_future, "
|
||||||
|
f"got {model_target_mode!r}"
|
||||||
|
)
|
||||||
dist_mode_cfg = str(cfg.get("dist_mode", "exponential"))
|
dist_mode_cfg = str(cfg.get("dist_mode", "exponential"))
|
||||||
attn_mask_mode = str(cfg.get(
|
attn_mask_mode = str(cfg.get(
|
||||||
"attn_mask_mode", "non_strict_time" if target_mode == "uts" else "target_aware"))
|
"attn_mask_mode", "non_strict_time" if target_mode == "uts" else "target_aware"))
|
||||||
@@ -1232,7 +1401,7 @@ def main() -> None:
|
|||||||
and dataset.label_id_to_code.get(NO_EVENT_IDX) == "<NO_EVENT>"
|
and dataset.label_id_to_code.get(NO_EVENT_IDX) == "<NO_EVENT>"
|
||||||
and dataset.vocab_size > NO_EVENT_IDX
|
and dataset.vocab_size > NO_EVENT_IDX
|
||||||
)
|
)
|
||||||
if not has_no_event:
|
if model_target_mode == "next_token" and not has_no_event:
|
||||||
print(
|
print(
|
||||||
"[SKIP] This checkpoint/run does not support <NO_EVENT> imputation. "
|
"[SKIP] This checkpoint/run does not support <NO_EVENT> imputation. "
|
||||||
"Landmark AUC requires inserting a <NO_EVENT> query token. "
|
"Landmark AUC requires inserting a <NO_EVENT> query token. "
|
||||||
@@ -1283,20 +1452,14 @@ def main() -> None:
|
|||||||
|
|
||||||
state_dict = load_checkpoint_state_dict(model_ckpt_path, map_location="cpu")
|
state_dict = load_checkpoint_state_dict(model_ckpt_path, map_location="cpu")
|
||||||
dist_mode = resolve_dist_mode_for_checkpoint(dist_mode_cfg, state_dict)
|
dist_mode = resolve_dist_mode_for_checkpoint(dist_mode_cfg, state_dict)
|
||||||
if dist_mode == "weibull":
|
if dist_mode not in {"exponential", "weibull", "mixed"}:
|
||||||
raise RuntimeError(
|
raise ValueError(
|
||||||
"Weibull checkpoints are not supported by evaluate_auc_v2.py. "
|
f"Unsupported dist_mode={dist_mode!r}; expected exponential, weibull, or mixed."
|
||||||
"This landmark evaluation requires <NO_EVENT> imputation, and Weibull runs "
|
|
||||||
"in this project are treated as unsupported for no-event landmark AUC. "
|
|
||||||
"Please use an exponential no-event checkpoint."
|
|
||||||
)
|
|
||||||
if score_mode == "risk" and dist_mode != "exponential":
|
|
||||||
raise RuntimeError(
|
|
||||||
"score_mode='risk' requires dist_mode='exponential' for evaluate_auc_v2.py."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if score_mode == "eta":
|
if score_mode == "eta":
|
||||||
print("WARNING: eta diagnostic score is not horizon-specific risk.")
|
print(
|
||||||
|
"WARNING: eta diagnostic score is not horizon-specific risk and does not use dist_mode-specific rho parameters.")
|
||||||
|
|
||||||
cfg_model = dict(cfg)
|
cfg_model = dict(cfg)
|
||||||
cfg_model["dist_mode"] = dist_mode
|
cfg_model["dist_mode"] = dist_mode
|
||||||
@@ -1308,7 +1471,13 @@ def main() -> None:
|
|||||||
|
|
||||||
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
||||||
|
|
||||||
if model.token_embedding.num_embeddings <= NO_EVENT_IDX or model.risk_head.out_features <= NO_EVENT_IDX:
|
if (
|
||||||
|
model_target_mode == "next_token"
|
||||||
|
and (
|
||||||
|
model.token_embedding.num_embeddings <= NO_EVENT_IDX
|
||||||
|
or model.risk_head.out_features <= NO_EVENT_IDX
|
||||||
|
)
|
||||||
|
):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Model vocabulary does not include <NO_EVENT> token index. "
|
"Model vocabulary does not include <NO_EVENT> token index. "
|
||||||
"Checkpoint/model shape is incompatible with no-event landmark querying."
|
"Checkpoint/model shape is incompatible with no-event landmark querying."
|
||||||
@@ -1335,6 +1504,7 @@ def main() -> None:
|
|||||||
subset_indices=subset_indices,
|
subset_indices=subset_indices,
|
||||||
landmark_ages=landmark_ages,
|
landmark_ages=landmark_ages,
|
||||||
attn_mask_mode=attn_mask_mode,
|
attn_mask_mode=attn_mask_mode,
|
||||||
|
model_target_mode=model_target_mode,
|
||||||
min_history_events=min_history_events,
|
min_history_events=min_history_events,
|
||||||
first_occurrence_by_token=first_occurrence_by_token,
|
first_occurrence_by_token=first_occurrence_by_token,
|
||||||
death_token_ids=death_token_ids,
|
death_token_ids=death_token_ids,
|
||||||
@@ -1357,11 +1527,12 @@ def main() -> None:
|
|||||||
if eval_split in {"valid", "validation"}:
|
if eval_split in {"valid", "validation"}:
|
||||||
eval_split = "val"
|
eval_split = "val"
|
||||||
|
|
||||||
score_mode_out = (
|
landmark_query_mode = (
|
||||||
"insert_no_event_landmark_exponential_risk"
|
"insert_no_event_token"
|
||||||
if score_mode == "risk"
|
if model_target_mode == "next_token"
|
||||||
else "insert_no_event_landmark_eta_diagnostic"
|
else "direct_t_query"
|
||||||
)
|
)
|
||||||
|
score_mode_out = f"{landmark_query_mode}_{score_mode}"
|
||||||
|
|
||||||
num_workers_auc = int(
|
num_workers_auc = int(
|
||||||
cfg_get(args, cfg, "num_workers_auc", max(1, (os.cpu_count() or 2) - 1)))
|
cfg_get(args, cfg, "num_workers_auc", max(1, (os.cpu_count() or 2) - 1)))
|
||||||
@@ -1377,9 +1548,14 @@ def main() -> None:
|
|||||||
|
|
||||||
print(f"Eval split: {eval_split}")
|
print(f"Eval split: {eval_split}")
|
||||||
print(f"Number of selected patients: {len(subset_indices)}")
|
print(f"Number of selected patients: {len(subset_indices)}")
|
||||||
print("No-event support: true")
|
print(f"No-event support: {bool(has_no_event)}")
|
||||||
print("Landmark query mode: insert_no_event_token")
|
print(f"Model target mode: {model_target_mode}")
|
||||||
print("Landmark token mode: no_event")
|
print(f"Landmark query mode: {landmark_query_mode}")
|
||||||
|
print(
|
||||||
|
"Landmark token mode: no_event"
|
||||||
|
if model_target_mode == "next_token"
|
||||||
|
else "Landmark token mode: none"
|
||||||
|
)
|
||||||
print(f"Dist mode: {dist_mode}")
|
print(f"Dist mode: {dist_mode}")
|
||||||
print(f"Score mode: {score_mode}")
|
print(f"Score mode: {score_mode}")
|
||||||
print(f"Landmark ages: {landmark_ages.tolist()}")
|
print(f"Landmark ages: {landmark_ages.tolist()}")
|
||||||
@@ -1395,12 +1571,13 @@ def main() -> None:
|
|||||||
"model_ckpt_path": str(model_ckpt_path),
|
"model_ckpt_path": str(model_ckpt_path),
|
||||||
"config_path": str(config_path),
|
"config_path": str(config_path),
|
||||||
"target_mode": str(target_mode),
|
"target_mode": str(target_mode),
|
||||||
|
"model_target_mode": str(model_target_mode),
|
||||||
"dist_mode": str(dist_mode),
|
"dist_mode": str(dist_mode),
|
||||||
"time_mode": str(time_mode),
|
"time_mode": str(time_mode),
|
||||||
"attn_mask_mode": str(attn_mask_mode),
|
"attn_mask_mode": str(attn_mask_mode),
|
||||||
"readout_name": str(readout_name),
|
"readout_name": str(readout_name),
|
||||||
"landmark_query_mode": "insert_no_event_token",
|
"landmark_query_mode": landmark_query_mode,
|
||||||
"landmark_token_mode": "no_event",
|
"landmark_token_mode": "no_event" if model_target_mode == "next_token" else "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate_landmark_auc(
|
evaluate_landmark_auc(
|
||||||
@@ -1414,6 +1591,7 @@ def main() -> None:
|
|||||||
score_mode=score_mode,
|
score_mode=score_mode,
|
||||||
horizons=horizons,
|
horizons=horizons,
|
||||||
device=device,
|
device=device,
|
||||||
|
model_target_mode=model_target_mode,
|
||||||
readout_name=readout_name,
|
readout_name=readout_name,
|
||||||
readout_reduce=readout_reduce,
|
readout_reduce=readout_reduce,
|
||||||
num_workers_auc=num_workers_auc,
|
num_workers_auc=num_workers_auc,
|
||||||
|
|||||||
Reference in New Issue
Block a user