2026-08-05 12:54:14 +08:00
|
|
|
"""Export individual Weibull parameters to one HDF5 file.
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
The test population is read from ``test_eid_file`` in the specified run's
|
|
|
|
|
``train_config.json``. Each eligible patient is queried at ages
|
|
|
|
|
40, 42, ..., 80 using only disease history observed by that age.
|
|
|
|
|
|
|
|
|
|
The model parameterization is
|
|
|
|
|
|
|
|
|
|
S(t) = exp(-rate * t ** shape)
|
|
|
|
|
|
|
|
|
|
and the standard Weibull parameters exported by this script are
|
|
|
|
|
|
|
|
|
|
shape = rho
|
|
|
|
|
scale = rate ** (-1 / shape)
|
|
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
The output is one HDF5 file. It contains the token table, age summary and one
|
|
|
|
|
group per landmark age. Each age group stores aligned patient metadata plus
|
|
|
|
|
chunked, compressed ``shape`` and ``scale`` matrices. Chunking is internal to
|
|
|
|
|
HDF5, so callers receive one file without loading the full multi-gigabyte
|
|
|
|
|
export into memory.
|
2026-08-05 11:02:35 +08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import contextlib
|
2026-08-05 12:54:14 +08:00
|
|
|
import importlib
|
2026-08-05 11:02:35 +08:00
|
|
|
import json
|
|
|
|
|
import math
|
|
|
|
|
from pathlib import Path
|
2026-08-05 12:54:14 +08:00
|
|
|
from typing import Any, Dict, List, Sequence
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import torch
|
|
|
|
|
import torch.nn.functional as F
|
|
|
|
|
from torch.utils.data import DataLoader
|
|
|
|
|
from tqdm.auto import tqdm
|
|
|
|
|
|
|
|
|
|
from dataset import (
|
|
|
|
|
DISEASE_HISTORY_MODE_TIMED,
|
|
|
|
|
NO_EVENT_IDX,
|
|
|
|
|
PAD_IDX,
|
|
|
|
|
RESERVED_IDX,
|
|
|
|
|
normalize_disease_history_mode,
|
|
|
|
|
)
|
|
|
|
|
from eval_data import (
|
|
|
|
|
build_model_from_dataset,
|
|
|
|
|
load_json_config,
|
|
|
|
|
load_sequence_eval_dataset,
|
|
|
|
|
resolve_eval_device,
|
|
|
|
|
select_indices_by_eid_file,
|
|
|
|
|
validate_dataset_metadata,
|
|
|
|
|
validate_training_mode_config,
|
|
|
|
|
)
|
|
|
|
|
from evaluate_auc_v2 import (
|
|
|
|
|
LandmarkDataset,
|
|
|
|
|
collate_landmark_fn,
|
|
|
|
|
load_checkpoint_state_dict,
|
|
|
|
|
load_model_state,
|
|
|
|
|
resolve_dist_mode_for_checkpoint,
|
|
|
|
|
)
|
|
|
|
|
from model_architectures import resolve_model_architecture
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SPECIAL_TOKENS = {PAD_IDX, RESERVED_IDX, NO_EVENT_IDX}
|
2026-08-05 12:54:14 +08:00
|
|
|
FORMAT_VERSION = 2
|
|
|
|
|
DEFAULT_COMPRESSION_LEVEL = 4
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description=(
|
|
|
|
|
"Export each test patient's complete disease/death Weibull "
|
|
|
|
|
"shape and scale matrices at two-year age landmarks."
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--run_path",
|
|
|
|
|
required=True,
|
|
|
|
|
help="Run directory containing train_config.json and best_model.pt.",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--output_path",
|
|
|
|
|
default=None,
|
|
|
|
|
help=(
|
2026-08-05 12:54:14 +08:00
|
|
|
"Single output HDF5 file. Defaults to "
|
|
|
|
|
"<run_path>/weibull_parameters_test_age40_80_step2.h5."
|
2026-08-05 11:02:35 +08:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--test_eid_file",
|
|
|
|
|
default=None,
|
|
|
|
|
help=(
|
|
|
|
|
"Optional override. By default use test_eid_file from the run's "
|
|
|
|
|
"train_config.json, or ukb_test_eid.csv if the field is absent."
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument("--age_start", type=float, default=40.0)
|
|
|
|
|
parser.add_argument("--age_stop", type=float, default=80.0)
|
|
|
|
|
parser.add_argument("--age_step", type=float, default=2.0)
|
|
|
|
|
parser.add_argument("--batch_size", type=int, default=128)
|
|
|
|
|
parser.add_argument(
|
2026-08-05 12:54:14 +08:00
|
|
|
"--rows_per_chunk",
|
2026-08-05 11:02:35 +08:00
|
|
|
type=int,
|
2026-08-05 12:54:14 +08:00
|
|
|
default=256,
|
2026-08-05 11:02:35 +08:00
|
|
|
help=(
|
2026-08-05 12:54:14 +08:00
|
|
|
"Patient rows per internal HDF5 chunk for shape/scale matrices. "
|
|
|
|
|
"This does not create separate output files."
|
2026-08-05 11:02:35 +08:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument("--num_workers", type=int, default=4)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--device",
|
|
|
|
|
default=None,
|
|
|
|
|
help="For example cpu, cuda, or cuda:1. Defaults to CUDA when available.",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--use_amp",
|
|
|
|
|
action=argparse.BooleanOptionalAction,
|
|
|
|
|
default=False,
|
|
|
|
|
help="Use float16 autocast during CUDA inference.",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--dataset_subset_size",
|
|
|
|
|
type=int,
|
|
|
|
|
default=None,
|
|
|
|
|
help="Use only the first N matched test patients for a smoke test.",
|
|
|
|
|
)
|
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_age_grid(start: float, stop: float, step: float) -> np.ndarray:
|
|
|
|
|
"""Build an inclusive age grid and reject a stop not aligned to the step."""
|
|
|
|
|
if not all(math.isfinite(x) for x in (start, stop, step)):
|
|
|
|
|
raise ValueError("Age start/stop/step must be finite.")
|
|
|
|
|
if step <= 0:
|
|
|
|
|
raise ValueError("age_step must be > 0.")
|
|
|
|
|
if stop < start:
|
|
|
|
|
raise ValueError("age_stop must be >= age_start.")
|
|
|
|
|
count = int(math.floor((stop - start) / step + 1e-9)) + 1
|
|
|
|
|
ages = start + step * np.arange(count, dtype=np.float64)
|
|
|
|
|
if ages.size == 0 or not math.isclose(
|
|
|
|
|
float(ages[-1]), stop, rel_tol=0.0, abs_tol=1e-7
|
|
|
|
|
):
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"age_stop must lie on the grid defined by age_start and age_step."
|
|
|
|
|
)
|
|
|
|
|
ages[-1] = stop
|
|
|
|
|
return ages.astype(np.float32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_int_list(value: Any) -> List[int] | None:
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(value, (list, tuple, np.ndarray)):
|
|
|
|
|
return [int(x) for x in value]
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
|
|
|
|
if text.startswith("["):
|
|
|
|
|
parsed = json.loads(text)
|
|
|
|
|
if not isinstance(parsed, list):
|
|
|
|
|
raise ValueError("extra_info_types must be a list of integers.")
|
|
|
|
|
return [int(x) for x in parsed]
|
|
|
|
|
return [int(x.strip()) for x in text.split(",") if x.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def select_outcome_tokens(dataset: Any) -> List[int]:
|
|
|
|
|
"""Select every real outcome token, including Death, in token-id order."""
|
|
|
|
|
tokens = sorted(
|
|
|
|
|
int(token)
|
|
|
|
|
for token, code in dataset.label_id_to_code.items()
|
|
|
|
|
if int(token) not in SPECIAL_TOKENS and not str(code).startswith("<")
|
|
|
|
|
)
|
|
|
|
|
if not tokens:
|
|
|
|
|
raise RuntimeError("The dataset contains no disease/death outcome tokens.")
|
|
|
|
|
return tokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_project_file(path_value: str | Path) -> Path:
|
|
|
|
|
path = Path(path_value)
|
|
|
|
|
if path.is_absolute():
|
|
|
|
|
return path
|
|
|
|
|
direct = Path.cwd() / path
|
|
|
|
|
return direct if direct.is_file() else Path(__file__).resolve().parent / path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_label_text(labels_file: str | Path) -> Dict[str, str]:
|
|
|
|
|
result: Dict[str, str] = {}
|
|
|
|
|
with resolve_project_file(labels_file).open("r", encoding="utf-8") as handle:
|
|
|
|
|
for line in handle:
|
|
|
|
|
text = line.strip()
|
|
|
|
|
if text:
|
|
|
|
|
result[text.split()[0]] = text
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
def require_h5py() -> Any:
|
|
|
|
|
"""Import h5py lazily so ``--help`` remains available without it."""
|
|
|
|
|
try:
|
|
|
|
|
return importlib.import_module("h5py")
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"This exporter writes one HDF5 file and requires h5py. Install "
|
|
|
|
|
"h5py in the same Python environment used for DeepHealth."
|
|
|
|
|
) from exc
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
def age_group_name(age: float) -> str:
|
2026-08-05 11:02:35 +08:00
|
|
|
text = f"{age:g}".replace("-", "minus_").replace(".", "p")
|
|
|
|
|
return f"age_{text}"
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
def write_metadata_json(
|
|
|
|
|
metadata_dataset: Any,
|
|
|
|
|
payload: Dict[str, Any],
|
|
|
|
|
) -> None:
|
|
|
|
|
metadata_dataset[()] = json.dumps(
|
|
|
|
|
payload,
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
separators=(",", ":"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_empty_age_group(
|
2026-08-05 11:02:35 +08:00
|
|
|
*,
|
|
|
|
|
age: float,
|
2026-08-05 12:54:14 +08:00
|
|
|
age_group: Any,
|
2026-08-05 11:02:35 +08:00
|
|
|
token_count: int,
|
|
|
|
|
) -> Dict[str, Any]:
|
2026-08-05 12:54:14 +08:00
|
|
|
"""Represent an age with no eligible patients inside the HDF5 file."""
|
|
|
|
|
age_group.attrs["age"] = float(age)
|
|
|
|
|
age_group.attrs["n_rows"] = 0
|
|
|
|
|
age_group.attrs["n_tokens"] = int(token_count)
|
|
|
|
|
age_group.attrs["nonfinite_shape_values"] = 0
|
|
|
|
|
age_group.attrs["nonfinite_scale_values"] = 0
|
|
|
|
|
age_group.create_dataset("eid", shape=(0,), dtype=np.int64)
|
|
|
|
|
age_group.create_dataset("dataset_index", shape=(0,), dtype=np.int64)
|
|
|
|
|
age_group.create_dataset("sex", shape=(0,), dtype=np.int8)
|
|
|
|
|
age_group.create_dataset("age", shape=(0,), dtype=np.float32)
|
|
|
|
|
age_group.create_dataset(
|
|
|
|
|
"shape", shape=(0, token_count), dtype=np.float32
|
|
|
|
|
)
|
|
|
|
|
age_group.create_dataset(
|
|
|
|
|
"scale", shape=(0, token_count), dtype=np.float32
|
2026-08-05 11:02:35 +08:00
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"age": float(age),
|
|
|
|
|
"n_rows": 0,
|
|
|
|
|
"n_tokens": int(token_count),
|
|
|
|
|
"nonfinite_shape_values": 0,
|
|
|
|
|
"nonfinite_scale_values": 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
def validate_hdf5_export(
|
|
|
|
|
output_file: Any,
|
|
|
|
|
*,
|
|
|
|
|
ages: np.ndarray,
|
|
|
|
|
token_count: int,
|
|
|
|
|
summaries: Sequence[Dict[str, Any]],
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Validate the unified file structure before it is finalized."""
|
|
|
|
|
for group_name in (
|
|
|
|
|
"tokens",
|
|
|
|
|
"test_population",
|
|
|
|
|
"landmarks",
|
|
|
|
|
"age_summary",
|
|
|
|
|
):
|
|
|
|
|
if group_name not in output_file:
|
|
|
|
|
raise RuntimeError(f"HDF5 export is missing /{group_name}.")
|
|
|
|
|
if output_file["tokens/token_id"].shape != (token_count,):
|
|
|
|
|
raise RuntimeError("HDF5 token table length does not match n_tokens.")
|
|
|
|
|
if len(summaries) != int(ages.size):
|
|
|
|
|
raise RuntimeError("HDF5 age summary length does not match age grid.")
|
|
|
|
|
|
|
|
|
|
for age_value, summary in zip(ages.tolist(), summaries):
|
|
|
|
|
age = float(age_value)
|
|
|
|
|
group_path = f"landmarks/{age_group_name(age)}"
|
|
|
|
|
if group_path not in output_file:
|
|
|
|
|
raise RuntimeError(f"HDF5 export is missing /{group_path}.")
|
|
|
|
|
group = output_file[group_path]
|
|
|
|
|
n_rows = int(summary["n_rows"])
|
|
|
|
|
matrix_shape = (n_rows, token_count)
|
|
|
|
|
if group["shape"].shape != matrix_shape:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"/{group_path}/shape has {group['shape'].shape}, expected "
|
|
|
|
|
f"{matrix_shape}."
|
|
|
|
|
)
|
|
|
|
|
if group["scale"].shape != matrix_shape:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"/{group_path}/scale has {group['scale'].shape}, expected "
|
|
|
|
|
f"{matrix_shape}."
|
|
|
|
|
)
|
|
|
|
|
for dataset_name in ("eid", "dataset_index", "sex", "age"):
|
|
|
|
|
if group[dataset_name].shape != (n_rows,):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"/{group_path}/{dataset_name} length does not match "
|
|
|
|
|
"the parameter matrices."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 11:02:35 +08:00
|
|
|
@torch.inference_mode()
|
|
|
|
|
def export_age(
|
|
|
|
|
*,
|
|
|
|
|
age: float,
|
|
|
|
|
model: Any,
|
|
|
|
|
loader: DataLoader,
|
|
|
|
|
tokens: Sequence[int],
|
|
|
|
|
device: torch.device,
|
|
|
|
|
use_amp: bool,
|
|
|
|
|
subset_indices: np.ndarray,
|
2026-08-05 12:54:14 +08:00
|
|
|
selected_eids: np.ndarray,
|
|
|
|
|
age_group: Any,
|
|
|
|
|
rows_per_chunk: int,
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""Write one age directly into its group in the unified HDF5 file."""
|
2026-08-05 11:02:35 +08:00
|
|
|
token_index = torch.as_tensor(tokens, dtype=torch.long, device=device)
|
2026-08-05 12:54:14 +08:00
|
|
|
amp_enabled = bool(use_amp and device.type == "cuda")
|
|
|
|
|
n_rows_expected = int(len(loader.dataset))
|
|
|
|
|
n_tokens = int(len(tokens))
|
|
|
|
|
age_group.attrs["age"] = float(age)
|
|
|
|
|
age_group.attrs["n_rows"] = n_rows_expected
|
|
|
|
|
age_group.attrs["n_tokens"] = n_tokens
|
|
|
|
|
|
|
|
|
|
if n_rows_expected == 0:
|
|
|
|
|
return write_empty_age_group(
|
|
|
|
|
age=age,
|
|
|
|
|
age_group=age_group,
|
|
|
|
|
token_count=n_tokens,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
row_chunk = min(int(rows_per_chunk), n_rows_expected)
|
|
|
|
|
vector_options = {
|
|
|
|
|
"chunks": (row_chunk,),
|
|
|
|
|
"compression": "gzip",
|
|
|
|
|
"compression_opts": DEFAULT_COMPRESSION_LEVEL,
|
|
|
|
|
"shuffle": True,
|
|
|
|
|
}
|
|
|
|
|
matrix_options = {
|
|
|
|
|
"chunks": (row_chunk, n_tokens),
|
|
|
|
|
"compression": "gzip",
|
|
|
|
|
"compression_opts": DEFAULT_COMPRESSION_LEVEL,
|
|
|
|
|
"shuffle": True,
|
|
|
|
|
}
|
|
|
|
|
eid_dataset = age_group.create_dataset(
|
|
|
|
|
"eid", shape=(n_rows_expected,), dtype=np.int64, **vector_options
|
|
|
|
|
)
|
|
|
|
|
dataset_index_dataset = age_group.create_dataset(
|
|
|
|
|
"dataset_index",
|
|
|
|
|
shape=(n_rows_expected,),
|
2026-08-05 11:02:35 +08:00
|
|
|
dtype=np.int64,
|
2026-08-05 12:54:14 +08:00
|
|
|
**vector_options,
|
2026-08-05 11:02:35 +08:00
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
sex_dataset = age_group.create_dataset(
|
|
|
|
|
"sex", shape=(n_rows_expected,), dtype=np.int8, **vector_options
|
|
|
|
|
)
|
|
|
|
|
age_dataset = age_group.create_dataset(
|
|
|
|
|
"age", shape=(n_rows_expected,), dtype=np.float32, **vector_options
|
|
|
|
|
)
|
|
|
|
|
shape_dataset = age_group.create_dataset(
|
|
|
|
|
"shape",
|
|
|
|
|
shape=(n_rows_expected, n_tokens),
|
|
|
|
|
dtype=np.float32,
|
|
|
|
|
**matrix_options,
|
|
|
|
|
)
|
|
|
|
|
scale_dataset = age_group.create_dataset(
|
|
|
|
|
"scale",
|
|
|
|
|
shape=(n_rows_expected, n_tokens),
|
|
|
|
|
dtype=np.float32,
|
|
|
|
|
**matrix_options,
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-05 11:02:35 +08:00
|
|
|
row_start = 0
|
2026-08-05 12:54:14 +08:00
|
|
|
nonfinite_shape = 0
|
|
|
|
|
nonfinite_scale = 0
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
for batch in tqdm(loader, desc=f"Age {age:g}", dynamic_ncols=True):
|
|
|
|
|
batch_device = {
|
|
|
|
|
key: (
|
|
|
|
|
value.to(device, non_blocking=True)
|
|
|
|
|
if isinstance(value, torch.Tensor)
|
|
|
|
|
else value
|
|
|
|
|
)
|
|
|
|
|
for key, value in batch.items()
|
|
|
|
|
}
|
|
|
|
|
amp_context = (
|
|
|
|
|
torch.autocast(device_type="cuda", dtype=torch.float16)
|
|
|
|
|
if amp_enabled
|
|
|
|
|
else contextlib.nullcontext()
|
|
|
|
|
)
|
|
|
|
|
with amp_context:
|
|
|
|
|
hidden = model(
|
|
|
|
|
event_seq=batch_device["event_seq"],
|
|
|
|
|
time_seq=batch_device["time_seq"],
|
|
|
|
|
sex=batch_device["sex"],
|
|
|
|
|
padding_mask=batch_device["padding_mask"],
|
|
|
|
|
t_query=batch_device["t_query"],
|
|
|
|
|
other_type=batch_device["other_type"],
|
|
|
|
|
other_value=batch_device["other_value"],
|
|
|
|
|
other_value_kind=batch_device["other_value_kind"],
|
|
|
|
|
other_time=batch_device["other_time"],
|
|
|
|
|
)
|
|
|
|
|
logits = model.calc_risk(hidden).index_select(1, token_index).float()
|
|
|
|
|
shape = (
|
|
|
|
|
model.calc_weibull_rho(hidden)
|
|
|
|
|
.index_select(1, token_index)
|
|
|
|
|
.float()
|
|
|
|
|
)
|
|
|
|
|
rate = F.softplus(logits) + 1e-8
|
|
|
|
|
scale = torch.exp(-torch.log(rate) / shape)
|
|
|
|
|
|
|
|
|
|
shape_np = shape.cpu().numpy().astype(np.float32, copy=False)
|
|
|
|
|
scale_np = scale.cpu().numpy().astype(np.float32, copy=False)
|
|
|
|
|
patient_id = batch["patient_id"].cpu().numpy().astype(np.int64)
|
|
|
|
|
dataset_index = subset_indices[patient_id]
|
|
|
|
|
n_rows = int(shape_np.shape[0])
|
2026-08-05 12:54:14 +08:00
|
|
|
row_stop = row_start + n_rows
|
|
|
|
|
if row_stop > n_rows_expected:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Age {age:g} produced more rows than expected: "
|
|
|
|
|
f"{row_stop} > {n_rows_expected}."
|
|
|
|
|
)
|
|
|
|
|
eid_dataset[row_start:row_stop] = selected_eids[patient_id]
|
|
|
|
|
dataset_index_dataset[row_start:row_stop] = dataset_index.astype(
|
|
|
|
|
np.int64, copy=False
|
2026-08-05 11:02:35 +08:00
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
sex_dataset[row_start:row_stop] = (
|
2026-08-05 11:02:35 +08:00
|
|
|
batch["sex"].cpu().numpy().astype(np.int8, copy=False)
|
|
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
age_dataset[row_start:row_stop] = (
|
2026-08-05 11:02:35 +08:00
|
|
|
batch["landmark_age"]
|
|
|
|
|
.cpu()
|
|
|
|
|
.numpy()
|
|
|
|
|
.astype(np.float32, copy=False)
|
|
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
shape_dataset[row_start:row_stop, :] = shape_np
|
|
|
|
|
scale_dataset[row_start:row_stop, :] = scale_np
|
|
|
|
|
nonfinite_shape += int((~np.isfinite(shape_np)).sum())
|
|
|
|
|
nonfinite_scale += int((~np.isfinite(scale_np)).sum())
|
|
|
|
|
row_start = row_stop
|
2026-08-05 11:02:35 +08:00
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
if row_start != n_rows_expected:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Age {age:g} row count mismatch: wrote {row_start}, "
|
|
|
|
|
f"expected {n_rows_expected}."
|
|
|
|
|
)
|
|
|
|
|
age_group.attrs["nonfinite_shape_values"] = nonfinite_shape
|
|
|
|
|
age_group.attrs["nonfinite_scale_values"] = nonfinite_scale
|
|
|
|
|
return {
|
|
|
|
|
"age": float(age),
|
|
|
|
|
"n_rows": n_rows_expected,
|
|
|
|
|
"n_tokens": n_tokens,
|
|
|
|
|
"nonfinite_shape_values": nonfinite_shape,
|
|
|
|
|
"nonfinite_scale_values": nonfinite_scale,
|
|
|
|
|
}
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
args = parse_args()
|
|
|
|
|
run_path = Path(args.run_path).resolve()
|
|
|
|
|
config_path = run_path / "train_config.json"
|
|
|
|
|
checkpoint_path = run_path / "best_model.pt"
|
|
|
|
|
if not config_path.is_file():
|
|
|
|
|
raise FileNotFoundError(config_path)
|
|
|
|
|
if not checkpoint_path.is_file():
|
|
|
|
|
raise FileNotFoundError(checkpoint_path)
|
|
|
|
|
if args.batch_size <= 0:
|
|
|
|
|
raise ValueError("batch_size must be > 0.")
|
2026-08-05 12:54:14 +08:00
|
|
|
if args.rows_per_chunk <= 0:
|
|
|
|
|
raise ValueError("rows_per_chunk must be > 0.")
|
2026-08-05 11:02:35 +08:00
|
|
|
if args.num_workers < 0:
|
|
|
|
|
raise ValueError("num_workers must be >= 0.")
|
|
|
|
|
if args.dataset_subset_size is not None and args.dataset_subset_size <= 0:
|
|
|
|
|
raise ValueError("dataset_subset_size must be > 0.")
|
|
|
|
|
|
|
|
|
|
cfg = load_json_config(config_path)
|
|
|
|
|
validate_training_mode_config(cfg)
|
|
|
|
|
model_target_mode = str(cfg.get("model_target_mode", "next_token")).lower()
|
|
|
|
|
if model_target_mode != "all_future":
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"This exporter requires model_target_mode='all_future'; got "
|
|
|
|
|
f"{model_target_mode!r}."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ages = build_age_grid(args.age_start, args.age_stop, args.age_step)
|
|
|
|
|
output_path = (
|
|
|
|
|
Path(args.output_path).resolve()
|
|
|
|
|
if args.output_path
|
2026-08-05 12:54:14 +08:00
|
|
|
else run_path / "weibull_parameters_test_age40_80_step2.h5"
|
2026-08-05 11:02:35 +08:00
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
if output_path.exists():
|
|
|
|
|
raise FileExistsError(
|
|
|
|
|
f"Output file already exists: {output_path}. Choose a new "
|
|
|
|
|
"--output_path."
|
|
|
|
|
)
|
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
temporary_output = output_path.with_name(f".{output_path.name}.partial")
|
|
|
|
|
if temporary_output.exists():
|
2026-08-05 11:02:35 +08:00
|
|
|
raise FileExistsError(
|
2026-08-05 12:54:14 +08:00
|
|
|
f"Partial output already exists: {temporary_output}. Remove or "
|
|
|
|
|
"rename it before retrying."
|
2026-08-05 11:02:35 +08:00
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
h5py = require_h5py()
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
data_prefix = str(cfg.get("data_prefix", "ukb"))
|
|
|
|
|
labels_file = str(cfg.get("labels_file", "labels.csv"))
|
|
|
|
|
disease_history_mode = normalize_disease_history_mode(
|
|
|
|
|
cfg.get("disease_history_mode", DISEASE_HISTORY_MODE_TIMED)
|
|
|
|
|
)
|
|
|
|
|
min_history_events = int(
|
|
|
|
|
cfg.get("all_future_min_history_events", cfg.get("min_history_events", 1))
|
|
|
|
|
)
|
|
|
|
|
min_future_events = int(
|
|
|
|
|
cfg.get("all_future_min_future_events", cfg.get("min_future_events", 1))
|
|
|
|
|
)
|
|
|
|
|
print("Loading dataset...")
|
|
|
|
|
dataset = load_sequence_eval_dataset(
|
|
|
|
|
model_target_mode=model_target_mode,
|
|
|
|
|
data_prefix=data_prefix,
|
|
|
|
|
labels_file=labels_file,
|
|
|
|
|
no_event_interval_years=float(cfg.get("no_event_interval_years", 5.0)),
|
|
|
|
|
min_history_events=min_history_events,
|
|
|
|
|
min_future_events=min_future_events,
|
|
|
|
|
extra_info_types=parse_int_list(cfg.get("extra_info_types")),
|
|
|
|
|
disease_history_mode=disease_history_mode,
|
|
|
|
|
)
|
|
|
|
|
validate_dataset_metadata(dataset, cfg)
|
|
|
|
|
|
|
|
|
|
test_eid_file = args.test_eid_file or cfg.get(
|
|
|
|
|
"test_eid_file", "ukb_test_eid.csv"
|
|
|
|
|
)
|
|
|
|
|
if not test_eid_file:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"No test_eid_file is defined. Provide --test_eid_file explicitly."
|
|
|
|
|
)
|
|
|
|
|
subset_indices, resolved_eid_file = select_indices_by_eid_file(
|
|
|
|
|
dataset, str(test_eid_file)
|
|
|
|
|
)
|
|
|
|
|
if args.dataset_subset_size is not None:
|
|
|
|
|
subset_indices = subset_indices[: args.dataset_subset_size]
|
|
|
|
|
if subset_indices.size == 0:
|
|
|
|
|
raise RuntimeError("The selected test subset is empty.")
|
|
|
|
|
|
|
|
|
|
state_dict = load_checkpoint_state_dict(checkpoint_path, map_location="cpu")
|
|
|
|
|
dist_mode = resolve_dist_mode_for_checkpoint(
|
|
|
|
|
str(cfg.get("dist_mode", "exponential")), state_dict
|
|
|
|
|
)
|
|
|
|
|
if dist_mode != "weibull":
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"The specified run uses dist_mode={dist_mode!r}, not 'weibull'."
|
|
|
|
|
)
|
|
|
|
|
cfg_model = dict(cfg)
|
|
|
|
|
cfg_model["dist_mode"] = dist_mode
|
|
|
|
|
cfg_model["model_architecture"] = resolve_model_architecture(
|
|
|
|
|
cfg_model, state_dict
|
|
|
|
|
)
|
|
|
|
|
device = resolve_eval_device(args.device)
|
|
|
|
|
model = build_model_from_dataset(
|
|
|
|
|
args, cfg_model, dataset, state_dict=state_dict
|
|
|
|
|
).to(device)
|
|
|
|
|
load_model_state(model, state_dict)
|
|
|
|
|
model.eval()
|
|
|
|
|
|
|
|
|
|
tokens = select_outcome_tokens(dataset)
|
|
|
|
|
label_text = load_label_text(labels_file)
|
|
|
|
|
death_tokens = [
|
|
|
|
|
token
|
|
|
|
|
for token in tokens
|
|
|
|
|
if str(dataset.label_id_to_code[token]).lower() == "death"
|
|
|
|
|
]
|
|
|
|
|
if not death_tokens:
|
|
|
|
|
raise RuntimeError("Death token was not found in the outcome vocabulary.")
|
|
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
token_codes = [str(dataset.label_id_to_code[token]) for token in tokens]
|
|
|
|
|
token_text = [label_text.get(code, code) for code in token_codes]
|
|
|
|
|
outcome_types = [
|
|
|
|
|
"death" if code.lower() == "death" else "disease"
|
|
|
|
|
for code in token_codes
|
2026-08-05 11:02:35 +08:00
|
|
|
]
|
2026-08-05 12:54:14 +08:00
|
|
|
selected_eids = np.asarray(
|
|
|
|
|
[int(dataset.samples[int(index)]["eid"]) for index in subset_indices],
|
|
|
|
|
dtype=np.int64,
|
2026-08-05 11:02:35 +08:00
|
|
|
)
|
|
|
|
|
metadata: Dict[str, Any] = {
|
2026-08-05 12:54:14 +08:00
|
|
|
"format_version": FORMAT_VERSION,
|
2026-08-05 11:02:35 +08:00
|
|
|
"complete": False,
|
|
|
|
|
"run_path": str(run_path),
|
|
|
|
|
"checkpoint": str(checkpoint_path),
|
|
|
|
|
"test_eid_file": str(resolved_eid_file),
|
|
|
|
|
"n_selected_test_patients": int(subset_indices.size),
|
|
|
|
|
"ages": [float(x) for x in ages],
|
|
|
|
|
"n_tokens": len(tokens),
|
|
|
|
|
"matrix_dtype": "float32",
|
2026-08-05 12:54:14 +08:00
|
|
|
"hdf5_layout": {
|
|
|
|
|
"tokens": (
|
|
|
|
|
"/tokens/{column,token_id,label_code,label_text,outcome_type}"
|
|
|
|
|
),
|
|
|
|
|
"test_population": "/test_population/{eid,dataset_index}",
|
|
|
|
|
"age_summary": (
|
|
|
|
|
"/age_summary/{age,n_rows,nonfinite_shape_values,"
|
|
|
|
|
"nonfinite_scale_values}"
|
|
|
|
|
),
|
|
|
|
|
"landmarks": (
|
|
|
|
|
"/landmarks/age_*/{eid,dataset_index,sex,age,shape,scale}"
|
|
|
|
|
),
|
2026-08-05 11:02:35 +08:00
|
|
|
},
|
2026-08-05 12:54:14 +08:00
|
|
|
"compression": "gzip",
|
|
|
|
|
"compression_level": DEFAULT_COMPRESSION_LEVEL,
|
|
|
|
|
"rows_per_chunk": int(args.rows_per_chunk),
|
2026-08-05 11:02:35 +08:00
|
|
|
"parameterization": {
|
|
|
|
|
"survival": "S(t) = exp(-rate * t^shape)",
|
|
|
|
|
"shape": "rho = softplus(rho_logit) + 1e-6",
|
|
|
|
|
"rate": "softplus(risk_logit) + 1e-8",
|
|
|
|
|
"scale": "rate^(-1/shape)",
|
|
|
|
|
"time_unit": "years after landmark age",
|
|
|
|
|
},
|
|
|
|
|
"eligibility": (
|
|
|
|
|
"At each age: follow-up extends beyond the landmark, the patient "
|
|
|
|
|
"is alive at the landmark, and the configured minimum disease "
|
|
|
|
|
"history is available. All disease and death token parameters are "
|
|
|
|
|
"exported, including tokens already prevalent by the landmark."
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
string_dtype = h5py.string_dtype(encoding="utf-8")
|
|
|
|
|
summaries: List[Dict[str, Any]] = []
|
|
|
|
|
with h5py.File(temporary_output, "w") as output_file:
|
|
|
|
|
output_file.attrs["format_version"] = FORMAT_VERSION
|
|
|
|
|
output_file.attrs["complete"] = False
|
|
|
|
|
output_file.attrs["run_path"] = str(run_path)
|
|
|
|
|
output_file.attrs["checkpoint"] = str(checkpoint_path)
|
|
|
|
|
output_file.attrs["test_eid_file"] = str(resolved_eid_file)
|
|
|
|
|
output_file.attrs["n_selected_test_patients"] = int(
|
|
|
|
|
subset_indices.size
|
|
|
|
|
)
|
|
|
|
|
output_file.attrs["n_tokens"] = int(len(tokens))
|
|
|
|
|
output_file.attrs["matrix_dtype"] = "float32"
|
|
|
|
|
output_file.attrs["compression"] = "gzip"
|
|
|
|
|
output_file.attrs["compression_level"] = DEFAULT_COMPRESSION_LEVEL
|
|
|
|
|
|
|
|
|
|
metadata_dataset = output_file.create_dataset(
|
|
|
|
|
"metadata_json", shape=(), dtype=string_dtype
|
|
|
|
|
)
|
|
|
|
|
write_metadata_json(metadata_dataset, metadata)
|
|
|
|
|
output_file.create_dataset("ages", data=ages.astype(np.float32))
|
|
|
|
|
|
|
|
|
|
token_group = output_file.create_group("tokens")
|
|
|
|
|
token_group.create_dataset(
|
|
|
|
|
"column", data=np.arange(len(tokens), dtype=np.int64)
|
|
|
|
|
)
|
|
|
|
|
token_group.create_dataset(
|
|
|
|
|
"token_id", data=np.asarray(tokens, dtype=np.int64)
|
|
|
|
|
)
|
|
|
|
|
token_group.create_dataset(
|
|
|
|
|
"label_code",
|
|
|
|
|
data=np.asarray(token_codes, dtype=object),
|
|
|
|
|
dtype=string_dtype,
|
|
|
|
|
)
|
|
|
|
|
token_group.create_dataset(
|
|
|
|
|
"label_text",
|
|
|
|
|
data=np.asarray(token_text, dtype=object),
|
|
|
|
|
dtype=string_dtype,
|
|
|
|
|
)
|
|
|
|
|
token_group.create_dataset(
|
|
|
|
|
"outcome_type",
|
|
|
|
|
data=np.asarray(outcome_types, dtype=object),
|
|
|
|
|
dtype=string_dtype,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
population_group = output_file.create_group("test_population")
|
|
|
|
|
population_group.create_dataset(
|
|
|
|
|
"eid", data=selected_eids, compression="gzip", shuffle=True
|
|
|
|
|
)
|
|
|
|
|
population_group.create_dataset(
|
|
|
|
|
"dataset_index",
|
|
|
|
|
data=subset_indices.astype(np.int64, copy=False),
|
|
|
|
|
compression="gzip",
|
|
|
|
|
shuffle=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
landmark_root = output_file.create_group("landmarks")
|
|
|
|
|
eligible_queries_by_age: Dict[str, int] = {}
|
|
|
|
|
for age_value in ages.tolist():
|
|
|
|
|
age = float(age_value)
|
|
|
|
|
age_group = landmark_root.create_group(age_group_name(age))
|
|
|
|
|
try:
|
|
|
|
|
landmark_dataset = LandmarkDataset(
|
|
|
|
|
dataset=dataset,
|
|
|
|
|
subset_indices=subset_indices,
|
|
|
|
|
landmark_ages=np.asarray([age], dtype=np.float32),
|
|
|
|
|
model_target_mode=model_target_mode,
|
|
|
|
|
min_history_events=min_history_events,
|
|
|
|
|
first_occurrence_by_token={},
|
|
|
|
|
death_token_ids=death_tokens,
|
|
|
|
|
disease_history_mode=disease_history_mode,
|
|
|
|
|
)
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
if "No eligible landmark query samples" not in str(exc):
|
|
|
|
|
raise
|
|
|
|
|
summary = write_empty_age_group(
|
2026-08-05 11:02:35 +08:00
|
|
|
age=age,
|
2026-08-05 12:54:14 +08:00
|
|
|
age_group=age_group,
|
2026-08-05 11:02:35 +08:00
|
|
|
token_count=len(tokens),
|
|
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
else:
|
|
|
|
|
loader = DataLoader(
|
|
|
|
|
landmark_dataset,
|
|
|
|
|
batch_size=int(args.batch_size),
|
|
|
|
|
shuffle=False,
|
|
|
|
|
collate_fn=collate_landmark_fn,
|
|
|
|
|
num_workers=int(args.num_workers),
|
|
|
|
|
pin_memory=device.type == "cuda",
|
|
|
|
|
persistent_workers=args.num_workers > 0,
|
|
|
|
|
prefetch_factor=2 if args.num_workers > 0 else None,
|
|
|
|
|
)
|
|
|
|
|
summary = export_age(
|
|
|
|
|
age=age,
|
|
|
|
|
model=model,
|
|
|
|
|
loader=loader,
|
|
|
|
|
tokens=tokens,
|
|
|
|
|
device=device,
|
|
|
|
|
use_amp=bool(args.use_amp),
|
|
|
|
|
subset_indices=subset_indices,
|
|
|
|
|
selected_eids=selected_eids,
|
|
|
|
|
age_group=age_group,
|
|
|
|
|
rows_per_chunk=int(args.rows_per_chunk),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
summaries.append(summary)
|
|
|
|
|
eligible_count = int(summary["n_rows"])
|
|
|
|
|
eligible_queries_by_age[f"{age:g}"] = eligible_count
|
|
|
|
|
metadata["eligible_queries_by_age"] = eligible_queries_by_age
|
|
|
|
|
write_metadata_json(metadata_dataset, metadata)
|
|
|
|
|
output_file.flush()
|
|
|
|
|
print(f"Age {age:g}: exported {eligible_count} patient rows")
|
|
|
|
|
|
|
|
|
|
summary_group = output_file.create_group("age_summary")
|
|
|
|
|
summary_group.create_dataset(
|
|
|
|
|
"age",
|
|
|
|
|
data=np.asarray([row["age"] for row in summaries], dtype=np.float32),
|
|
|
|
|
)
|
|
|
|
|
summary_group.create_dataset(
|
|
|
|
|
"n_rows",
|
|
|
|
|
data=np.asarray([row["n_rows"] for row in summaries], dtype=np.int64),
|
|
|
|
|
)
|
|
|
|
|
summary_group.create_dataset(
|
|
|
|
|
"nonfinite_shape_values",
|
|
|
|
|
data=np.asarray(
|
|
|
|
|
[row["nonfinite_shape_values"] for row in summaries],
|
|
|
|
|
dtype=np.int64,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
summary_group.create_dataset(
|
|
|
|
|
"nonfinite_scale_values",
|
|
|
|
|
data=np.asarray(
|
|
|
|
|
[row["nonfinite_scale_values"] for row in summaries],
|
|
|
|
|
dtype=np.int64,
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-08-05 11:02:35 +08:00
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
nonfinite_shape = sum(
|
|
|
|
|
int(row["nonfinite_shape_values"]) for row in summaries
|
|
|
|
|
)
|
|
|
|
|
nonfinite_scale = sum(
|
|
|
|
|
int(row["nonfinite_scale_values"]) for row in summaries
|
|
|
|
|
)
|
|
|
|
|
metadata["total_exported_query_rows"] = sum(
|
|
|
|
|
int(row["n_rows"]) for row in summaries
|
|
|
|
|
)
|
|
|
|
|
metadata["nonfinite_shape_values"] = nonfinite_shape
|
|
|
|
|
metadata["nonfinite_scale_values"] = nonfinite_scale
|
|
|
|
|
validate_hdf5_export(
|
|
|
|
|
output_file,
|
|
|
|
|
ages=ages,
|
|
|
|
|
token_count=len(tokens),
|
|
|
|
|
summaries=summaries,
|
|
|
|
|
)
|
|
|
|
|
metadata["validated"] = True
|
|
|
|
|
metadata["complete"] = True
|
|
|
|
|
write_metadata_json(metadata_dataset, metadata)
|
|
|
|
|
output_file.attrs["validated"] = True
|
|
|
|
|
output_file.attrs.modify("complete", True)
|
|
|
|
|
output_file.flush()
|
2026-08-05 11:02:35 +08:00
|
|
|
|
2026-08-05 12:54:14 +08:00
|
|
|
temporary_output.replace(output_path)
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
if nonfinite_shape or nonfinite_scale:
|
|
|
|
|
print(
|
|
|
|
|
"WARNING: exported non-finite values: "
|
|
|
|
|
f"shape={nonfinite_shape}, scale={nonfinite_scale}."
|
|
|
|
|
)
|
2026-08-05 12:54:14 +08:00
|
|
|
print(f"Saved unified Weibull parameter file to: {output_path}")
|
2026-08-05 11:02:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|