628 lines
22 KiB
Python
628 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import sys
|
|
import time
|
|
import csv
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, Tuple
|
|
|
|
import numpy as np
|
|
import torch
|
|
from torch.optim import AdamW
|
|
from torch.utils.data import Subset
|
|
|
|
from dataset import AllFutureHealthDataset, HealthDataset
|
|
from models import DeepHealth
|
|
from targets import NO_EVENT_IDX, PAD_IDX, RESERVED_IDX
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ContinuousRobustScalerStats:
|
|
"""Train-split robust scaling statistics aligned to ``cont_type_ids``."""
|
|
|
|
cont_type_ids: tuple[int, ...]
|
|
center: np.ndarray
|
|
scale: np.ndarray
|
|
observation_count: np.ndarray
|
|
quantile_range: tuple[float, float] = (25.0, 75.0)
|
|
|
|
def as_metadata(self) -> Dict[str, Any]:
|
|
return {
|
|
"method": "robust",
|
|
"fitted_on": "train_subset",
|
|
"quantile_range": [float(x) for x in self.quantile_range],
|
|
"cont_type_ids": [int(x) for x in self.cont_type_ids],
|
|
"observation_count": [int(x) for x in self.observation_count.tolist()],
|
|
"center_buffer": "tokenizer.continuous_value_center",
|
|
"scale_buffer": "tokenizer.continuous_value_scale",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AllFutureBaselineStats:
|
|
"""Training-only marginal first-onset rates for output initialization."""
|
|
|
|
event_count: np.ndarray
|
|
at_risk_exposure: np.ndarray
|
|
rate: np.ndarray
|
|
bias: np.ndarray
|
|
query_count: int
|
|
rate_floor: float
|
|
rate_ceiling: float
|
|
|
|
def as_metadata(self) -> Dict[str, Any]:
|
|
positive = self.rate[self.at_risk_exposure > 0]
|
|
if positive.size:
|
|
rate_summary = {
|
|
"min": float(positive.min()),
|
|
"median": float(np.median(positive)),
|
|
"max": float(positive.max()),
|
|
}
|
|
else:
|
|
rate_summary = {"min": 0.0, "median": 0.0, "max": 0.0}
|
|
return {
|
|
"method": "training_marginal_first_onset_rate",
|
|
"fitted_on": "one_seeded_query_per_training_patient",
|
|
"query_distribution": "patient_interval_time_uniform",
|
|
"query_count": int(self.query_count),
|
|
"observed_first_onsets": int(self.event_count.sum()),
|
|
"rate_floor": float(self.rate_floor),
|
|
"rate_ceiling": float(self.rate_ceiling),
|
|
"rate_per_year": rate_summary,
|
|
"checkpoint_parameter": "risk_head.bias",
|
|
}
|
|
|
|
|
|
def fit_all_future_baseline(
|
|
dataset: AllFutureHealthDataset,
|
|
subset: Subset,
|
|
*,
|
|
seed: int,
|
|
ignored_idx: Iterable[int] = (PAD_IDX, RESERVED_IDX, NO_EVENT_IDX),
|
|
rate_floor: float = 1e-6,
|
|
rate_ceiling: float = 10.0,
|
|
) -> AllFutureBaselineStats:
|
|
"""Fit marginal per-outcome rates under the training query distribution."""
|
|
if subset.dataset is not dataset:
|
|
raise ValueError("subset must reference the all-future training dataset")
|
|
if rate_floor <= 0 or rate_ceiling <= rate_floor:
|
|
raise ValueError("Require 0 < rate_floor < rate_ceiling")
|
|
|
|
subset_indices = np.asarray(subset.indices, dtype=np.int64)
|
|
if subset_indices.ndim != 1 or subset_indices.size == 0:
|
|
raise ValueError("training subset must contain at least one patient")
|
|
|
|
vocab_size = int(dataset.vocab_size)
|
|
ignored = {
|
|
int(idx)
|
|
for idx in ignored_idx
|
|
if 0 <= int(idx) < vocab_size
|
|
}
|
|
event_count = np.zeros(vocab_size, dtype=np.int64)
|
|
exposure_adjustment = np.zeros(vocab_size, dtype=np.float64)
|
|
common_exposure = 0.0
|
|
rng = np.random.RandomState(int(seed))
|
|
|
|
for patient_index in subset_indices.tolist():
|
|
patient = dataset.patients[int(patient_index)]
|
|
t_query = dataset.sample_query(patient, rng)
|
|
times = np.asarray(patient["times"], dtype=np.float64)
|
|
labels = np.asarray(patient["labels"], dtype=np.int64)
|
|
censor_time = max(float(patient["t_obs"]) - t_query, 0.0)
|
|
common_exposure += censor_time
|
|
|
|
prevalent = {
|
|
int(label)
|
|
for label in labels[times <= t_query].tolist()
|
|
if 0 <= int(label) < vocab_size and int(label) not in ignored
|
|
}
|
|
for label in prevalent:
|
|
exposure_adjustment[label] -= censor_time
|
|
|
|
# Keep the earliest future occurrence defensively; prepared disease
|
|
# events are already deduplicated to their first occurrence.
|
|
first_future_dt: Dict[int, float] = {}
|
|
for label, event_time in zip(labels[times > t_query], times[times > t_query]):
|
|
label = int(label)
|
|
if label in ignored or label in prevalent or not (0 <= label < vocab_size):
|
|
continue
|
|
event_dt = max(float(event_time) - t_query, 0.0)
|
|
previous = first_future_dt.get(label)
|
|
if previous is None or event_dt < previous:
|
|
first_future_dt[label] = event_dt
|
|
for label, event_dt in first_future_dt.items():
|
|
event_count[label] += 1
|
|
exposure_adjustment[label] += event_dt - censor_time
|
|
|
|
at_risk_exposure = common_exposure + exposure_adjustment
|
|
at_risk_exposure = np.maximum(at_risk_exposure, 0.0)
|
|
rate = np.full(vocab_size, float(rate_floor), dtype=np.float64)
|
|
has_exposure = at_risk_exposure > 0
|
|
rate[has_exposure] = (
|
|
event_count[has_exposure].astype(np.float64)
|
|
/ at_risk_exposure[has_exposure]
|
|
)
|
|
rate = np.clip(rate, float(rate_floor), float(rate_ceiling))
|
|
|
|
for idx in ignored:
|
|
at_risk_exposure[idx] = 0.0
|
|
rate[idx] = 0.0
|
|
bias = np.zeros(vocab_size, dtype=np.float64)
|
|
valid = np.ones(vocab_size, dtype=bool)
|
|
if ignored:
|
|
valid[np.asarray(sorted(ignored), dtype=np.int64)] = False
|
|
bias[valid] = np.log(np.expm1(rate[valid]))
|
|
|
|
if not np.isfinite(bias).all():
|
|
raise RuntimeError("All-future baseline initialization is non-finite")
|
|
return AllFutureBaselineStats(
|
|
event_count=event_count,
|
|
at_risk_exposure=at_risk_exposure.astype(np.float32),
|
|
rate=rate.astype(np.float32),
|
|
bias=bias.astype(np.float32),
|
|
query_count=int(subset_indices.size),
|
|
rate_floor=float(rate_floor),
|
|
rate_ceiling=float(rate_ceiling),
|
|
)
|
|
|
|
|
|
def fit_continuous_robust_scaler(
|
|
dataset: HealthDataset | AllFutureHealthDataset,
|
|
subset: Subset,
|
|
*,
|
|
quantile_range: tuple[float, float] = (25.0, 75.0),
|
|
scale_epsilon: float = 1e-6,
|
|
) -> ContinuousRobustScalerStats:
|
|
"""Fit median/IQR statistics using only patients in the training subset.
|
|
|
|
The prepared arrays remain unchanged. Scaling is performed later inside the
|
|
model tokenizer so the fitted center and scale can live in the checkpoint.
|
|
"""
|
|
|
|
if subset.dataset is not dataset:
|
|
raise ValueError("subset must reference the dataset used to fit the scaler")
|
|
low, high = (float(quantile_range[0]), float(quantile_range[1]))
|
|
if not (0.0 <= low < high <= 100.0):
|
|
raise ValueError(
|
|
"quantile_range must satisfy 0 <= low < high <= 100, got "
|
|
f"{quantile_range!r}"
|
|
)
|
|
if scale_epsilon <= 0:
|
|
raise ValueError("scale_epsilon must be > 0")
|
|
|
|
cont_type_ids = tuple(int(x) for x in dataset.cont_type_ids)
|
|
n_cont_types = len(cont_type_ids)
|
|
if n_cont_types == 0:
|
|
empty = np.zeros(0, dtype=np.float32)
|
|
return ContinuousRobustScalerStats(
|
|
cont_type_ids=cont_type_ids,
|
|
center=empty.copy(),
|
|
scale=empty.copy(),
|
|
observation_count=np.zeros(0, dtype=np.int64),
|
|
quantile_range=(low, high),
|
|
)
|
|
|
|
subset_indices = np.asarray(subset.indices, dtype=np.int64)
|
|
if subset_indices.ndim != 1 or subset_indices.size == 0:
|
|
raise ValueError("training subset must contain at least one patient")
|
|
|
|
type_to_column = np.full(int(dataset.n_types), -1, dtype=np.int64)
|
|
for column, type_id in enumerate(cont_type_ids):
|
|
if type_id <= 0 or type_id >= len(type_to_column):
|
|
raise ValueError(
|
|
f"continuous type id {type_id} is outside [1, {len(type_to_column)})"
|
|
)
|
|
type_to_column[type_id] = column
|
|
|
|
if hasattr(dataset, "patients"):
|
|
records = dataset.patients
|
|
elif hasattr(dataset, "samples"):
|
|
records = dataset.samples
|
|
else:
|
|
raise TypeError(
|
|
"dataset must expose patient records through .patients or .samples"
|
|
)
|
|
|
|
values = np.full(
|
|
(int(subset_indices.size), n_cont_types),
|
|
np.nan,
|
|
dtype=np.float32,
|
|
)
|
|
for row, patient_index in enumerate(subset_indices.tolist()):
|
|
patient = records[int(patient_index)]
|
|
other_type = np.asarray(patient["other_type"], dtype=np.int64)
|
|
other_value = np.asarray(patient["other_value"], dtype=np.float32)
|
|
other_kind = np.asarray(patient["other_value_kind"], dtype=np.int64)
|
|
continuous = other_kind == 1
|
|
if not np.any(continuous):
|
|
continue
|
|
selected_type = other_type[continuous]
|
|
selected_value = other_value[continuous]
|
|
valid_type = (selected_type > 0) & (selected_type < len(type_to_column))
|
|
columns = np.full(selected_type.shape, -1, dtype=np.int64)
|
|
columns[valid_type] = type_to_column[selected_type[valid_type]]
|
|
valid = (columns >= 0) & np.isfinite(selected_value)
|
|
values[row, columns[valid]] = selected_value[valid]
|
|
|
|
observation_count = np.isfinite(values).sum(axis=0).astype(np.int64)
|
|
missing_types = [
|
|
type_id
|
|
for type_id, count in zip(cont_type_ids, observation_count.tolist())
|
|
if count == 0
|
|
]
|
|
if missing_types:
|
|
raise ValueError(
|
|
"Training subset has no finite observations for continuous type ids: "
|
|
f"{missing_types}"
|
|
)
|
|
|
|
low_value, center, high_value = np.nanpercentile(
|
|
values,
|
|
[low, 50.0, high],
|
|
axis=0,
|
|
)
|
|
scale = high_value - low_value
|
|
near_constant = (~np.isfinite(scale)) | (np.abs(scale) <= float(scale_epsilon))
|
|
scale[near_constant] = 1.0
|
|
|
|
center = np.asarray(center, dtype=np.float32)
|
|
scale = np.asarray(scale, dtype=np.float32)
|
|
if not np.isfinite(center).all():
|
|
raise RuntimeError("Robust scaler produced non-finite center values")
|
|
if not np.isfinite(scale).all() or np.any(scale <= 0):
|
|
raise RuntimeError("Robust scaler produced invalid scale values")
|
|
|
|
return ContinuousRobustScalerStats(
|
|
cont_type_ids=cont_type_ids,
|
|
center=center,
|
|
scale=scale,
|
|
observation_count=observation_count,
|
|
quantile_range=(low, high),
|
|
)
|
|
|
|
|
|
def create_unique_run_dir(name_fn, runs_root: Path = Path("runs")) -> tuple[Path, str]:
|
|
while True:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
run_name = name_fn(timestamp)
|
|
run_dir = runs_root / run_name
|
|
try:
|
|
run_dir.mkdir(parents=True, exist_ok=False)
|
|
return run_dir, run_name
|
|
except FileExistsError:
|
|
time.sleep(1.0)
|
|
|
|
|
|
def setup_logging(run_dir: Path) -> logging.Logger:
|
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
logger = logging.getLogger("DeepHealth")
|
|
logger.setLevel(logging.INFO)
|
|
logger.handlers.clear()
|
|
|
|
formatter = logging.Formatter(
|
|
"%(asctime)s - %(levelname)s - %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
console_handler.setLevel(logging.INFO)
|
|
console_handler.setFormatter(formatter)
|
|
logger.addHandler(console_handler)
|
|
|
|
file_handler = logging.FileHandler(run_dir / "train.log", mode="w")
|
|
file_handler.setLevel(logging.INFO)
|
|
file_handler.setFormatter(formatter)
|
|
logger.addHandler(file_handler)
|
|
|
|
return logger
|
|
|
|
|
|
def set_seed(seed: int) -> None:
|
|
np.random.seed(seed)
|
|
torch.manual_seed(seed)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.manual_seed(seed)
|
|
|
|
|
|
def load_extra_info_types_file(path: str) -> list[int]:
|
|
file_path = Path(path)
|
|
if not file_path.is_file():
|
|
raise FileNotFoundError(f"extra_info_types_file not found: {path}")
|
|
|
|
text = file_path.read_text(encoding="utf-8").strip()
|
|
if not text:
|
|
return []
|
|
|
|
if text.startswith("["):
|
|
raw_items = json.loads(text)
|
|
if not isinstance(raw_items, list):
|
|
raise ValueError("extra_info_types_file JSON must be a list")
|
|
else:
|
|
raw_items = []
|
|
for line in text.splitlines():
|
|
line = line.split("#", 1)[0].strip()
|
|
if line:
|
|
raw_items.extend(line.replace(",", " ").replace(";", " ").split())
|
|
|
|
try:
|
|
return [int(x) for x in raw_items]
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"Invalid extra info type id in {path}") from exc
|
|
|
|
|
|
def format_extra_info_types(extra_info_types: Iterable[int] | None) -> str:
|
|
if extra_info_types is None:
|
|
return "all"
|
|
values = [int(x) for x in extra_info_types]
|
|
if not values:
|
|
return "none"
|
|
return str(values)
|
|
|
|
|
|
def load_eid_file(path: str | Path) -> set[int]:
|
|
file_path = Path(path)
|
|
if not file_path.is_file():
|
|
raise FileNotFoundError(f"eid split file not found: {file_path}")
|
|
with file_path.open(newline="", encoding="utf-8-sig") as f:
|
|
reader = csv.DictReader(f)
|
|
if reader.fieldnames is None or "eid" not in reader.fieldnames:
|
|
raise ValueError(
|
|
f"eid split file must contain an 'eid' column: {file_path}"
|
|
)
|
|
out: set[int] = set()
|
|
for row in reader:
|
|
raw = (row.get("eid") or "").strip()
|
|
if raw:
|
|
out.add(int(raw))
|
|
if not out:
|
|
raise ValueError(f"eid split file is empty: {file_path}")
|
|
return out
|
|
|
|
|
|
def configure_torch_for_training(device: torch.device) -> None:
|
|
if device.type != "cuda":
|
|
return
|
|
torch.backends.cuda.matmul.allow_tf32 = True
|
|
torch.backends.cudnn.allow_tf32 = True
|
|
if hasattr(torch, "set_float32_matmul_precision"):
|
|
torch.set_float32_matmul_precision("high")
|
|
|
|
|
|
def resolve_device(device_arg: str) -> torch.device:
|
|
requested = device_arg.strip().lower()
|
|
if requested == "cpu":
|
|
return torch.device("cpu")
|
|
if requested == "cuda":
|
|
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
if requested.startswith("cuda:"):
|
|
if not torch.cuda.is_available():
|
|
return torch.device("cpu")
|
|
index = int(requested.split(":", 1)[1])
|
|
if index < 0 or index >= torch.cuda.device_count():
|
|
raise ValueError(f"Requested CUDA device is out of range: {device_arg}")
|
|
return torch.device(f"cuda:{index}")
|
|
raise ValueError(f"Unsupported device: {device_arg}")
|
|
|
|
|
|
def get_lr(epoch: int, args: Any, adaptive_lr: float) -> float:
|
|
if epoch < args.warmup_epochs:
|
|
return adaptive_lr * (epoch + 1) / args.warmup_epochs
|
|
progress = (epoch - args.warmup_epochs) / max(
|
|
1, args.max_epochs - args.warmup_epochs
|
|
)
|
|
cosine = 0.5 * (1 + math.cos(math.pi * progress))
|
|
return adaptive_lr * (
|
|
args.min_lr_ratio + cosine * (1 - args.min_lr_ratio)
|
|
)
|
|
|
|
|
|
def move_batch_to_device(
|
|
batch: Dict[str, torch.Tensor],
|
|
device: torch.device,
|
|
) -> Dict[str, torch.Tensor]:
|
|
non_blocking = device.type == "cuda"
|
|
return {
|
|
key: value.to(device, non_blocking=non_blocking)
|
|
if isinstance(value, torch.Tensor)
|
|
else value
|
|
for key, value in batch.items()
|
|
}
|
|
|
|
|
|
def split_dataset(
|
|
dataset: HealthDataset,
|
|
train_ratio: float,
|
|
val_ratio: float,
|
|
test_ratio: float,
|
|
seed: int,
|
|
) -> Tuple[Subset, Subset, Subset]:
|
|
total = train_ratio + val_ratio + test_ratio
|
|
if not np.isclose(total, 1.0, atol=1e-6):
|
|
raise ValueError(f"train/val/test ratios must sum to 1.0, got {total}")
|
|
|
|
indices = np.random.RandomState(seed).permutation(len(dataset))
|
|
n_train = int(len(dataset) * train_ratio)
|
|
n_val = int(len(dataset) * val_ratio)
|
|
return (
|
|
Subset(dataset, indices[:n_train]),
|
|
Subset(dataset, indices[n_train:n_train + n_val]),
|
|
Subset(dataset, indices[n_train + n_val:]),
|
|
)
|
|
|
|
|
|
def split_dataset_by_eid_files(
|
|
dataset: HealthDataset,
|
|
train_eid_file: str | Path,
|
|
val_eid_file: str | Path,
|
|
test_eid_file: str | Path,
|
|
) -> Tuple[Subset, Subset, Subset]:
|
|
split_sets = {
|
|
"train": load_eid_file(train_eid_file),
|
|
"val": load_eid_file(val_eid_file),
|
|
"test": load_eid_file(test_eid_file),
|
|
}
|
|
overlaps = (
|
|
split_sets["train"] & split_sets["val"],
|
|
split_sets["train"] & split_sets["test"],
|
|
split_sets["val"] & split_sets["test"],
|
|
)
|
|
if any(overlaps):
|
|
raise ValueError("eid split files must be disjoint")
|
|
|
|
split_indices: Dict[str, list[int]] = {"train": [], "val": [], "test": []}
|
|
for idx, sample in enumerate(dataset.samples):
|
|
eid = int(sample["eid"])
|
|
for split_name, eid_set in split_sets.items():
|
|
if eid in eid_set:
|
|
split_indices[split_name].append(idx)
|
|
break
|
|
|
|
missing = [name for name, indices in split_indices.items() if not indices]
|
|
if missing:
|
|
raise ValueError(f"Empty dataset split(s) after eid filtering: {missing}")
|
|
|
|
return (
|
|
Subset(dataset, np.asarray(split_indices["train"], dtype=np.int64)),
|
|
Subset(dataset, np.asarray(split_indices["val"], dtype=np.int64)),
|
|
Subset(dataset, np.asarray(split_indices["test"], dtype=np.int64)),
|
|
)
|
|
|
|
|
|
def split_all_future_datasets(
|
|
train_dataset: AllFutureHealthDataset,
|
|
val_dataset: AllFutureHealthDataset,
|
|
test_dataset: AllFutureHealthDataset,
|
|
train_ratio: float,
|
|
val_ratio: float,
|
|
test_ratio: float,
|
|
seed: int,
|
|
) -> Tuple[Subset, Subset, Subset]:
|
|
total = train_ratio + val_ratio + test_ratio
|
|
if not np.isclose(total, 1.0, atol=1e-6):
|
|
raise ValueError(f"train/val/test ratios must sum to 1.0, got {total}")
|
|
|
|
patient_indices = np.random.RandomState(seed).permutation(len(train_dataset.patients))
|
|
n_train = int(len(patient_indices) * train_ratio)
|
|
n_val = int(len(patient_indices) * val_ratio)
|
|
train_patient_idx = patient_indices[:n_train]
|
|
val_patient_set = set(int(x) for x in patient_indices[n_train:n_train + n_val])
|
|
test_patient_set = set(int(x) for x in patient_indices[n_train + n_val:])
|
|
|
|
val_query_idx = [
|
|
i for i, (pidx, _t_query) in enumerate(val_dataset.valid_queries)
|
|
if int(pidx) in val_patient_set
|
|
]
|
|
test_query_idx = [
|
|
i for i, (pidx, _t_query) in enumerate(test_dataset.valid_queries)
|
|
if int(pidx) in test_patient_set
|
|
]
|
|
if not val_query_idx:
|
|
raise ValueError("All-future validation split has no valid query samples.")
|
|
if not test_query_idx:
|
|
raise ValueError("All-future test split has no valid query samples.")
|
|
|
|
return (
|
|
Subset(train_dataset, train_patient_idx),
|
|
Subset(val_dataset, np.asarray(val_query_idx, dtype=np.int64)),
|
|
Subset(test_dataset, np.asarray(test_query_idx, dtype=np.int64)),
|
|
)
|
|
|
|
|
|
def split_all_future_datasets_by_eid_files(
|
|
train_dataset: AllFutureHealthDataset,
|
|
val_dataset: AllFutureHealthDataset,
|
|
test_dataset: AllFutureHealthDataset,
|
|
train_eid_file: str | Path,
|
|
val_eid_file: str | Path,
|
|
test_eid_file: str | Path,
|
|
) -> Tuple[Subset, Subset, Subset]:
|
|
split_sets = {
|
|
"train": load_eid_file(train_eid_file),
|
|
"val": load_eid_file(val_eid_file),
|
|
"test": load_eid_file(test_eid_file),
|
|
}
|
|
overlaps = (
|
|
split_sets["train"] & split_sets["val"],
|
|
split_sets["train"] & split_sets["test"],
|
|
split_sets["val"] & split_sets["test"],
|
|
)
|
|
if any(overlaps):
|
|
raise ValueError("eid split files must be disjoint")
|
|
|
|
train_patient_idx = [
|
|
idx
|
|
for idx, patient in enumerate(train_dataset.patients)
|
|
if int(patient["eid"]) in split_sets["train"]
|
|
]
|
|
val_query_idx = [
|
|
idx
|
|
for idx, (pidx, _t_query) in enumerate(val_dataset.valid_queries)
|
|
if int(val_dataset.patients[int(pidx)]["eid"]) in split_sets["val"]
|
|
]
|
|
test_query_idx = [
|
|
idx
|
|
for idx, (pidx, _t_query) in enumerate(test_dataset.valid_queries)
|
|
if int(test_dataset.patients[int(pidx)]["eid"]) in split_sets["test"]
|
|
]
|
|
|
|
if not train_patient_idx:
|
|
raise ValueError("All-future training eid split has no patients.")
|
|
if not val_query_idx:
|
|
raise ValueError("All-future validation eid split has no valid query samples.")
|
|
if not test_query_idx:
|
|
raise ValueError("All-future test eid split has no valid query samples.")
|
|
|
|
return (
|
|
Subset(train_dataset, np.asarray(train_patient_idx, dtype=np.int64)),
|
|
Subset(val_dataset, np.asarray(val_query_idx, dtype=np.int64)),
|
|
Subset(test_dataset, np.asarray(test_query_idx, dtype=np.int64)),
|
|
)
|
|
|
|
|
|
def get_model_parameter_counts(model: torch.nn.Module) -> Dict[str, int]:
|
|
"""Return stable total and trainable parameter counts."""
|
|
return {
|
|
"model_parameter_count": sum(
|
|
parameter.numel() for parameter in model.parameters()
|
|
),
|
|
"trainable_parameter_count": sum(
|
|
parameter.numel()
|
|
for parameter in model.parameters()
|
|
if parameter.requires_grad
|
|
),
|
|
}
|
|
|
|
|
|
def set_optimizer_lr(optimizer: AdamW, lr: float) -> None:
|
|
for param_group in optimizer.param_groups:
|
|
param_group["lr"] = lr
|
|
|
|
|
|
def save_checkpoint(model: DeepHealth, checkpoint_path: Path) -> None:
|
|
torch.save(model.state_dict(), checkpoint_path)
|
|
|
|
|
|
def save_config(
|
|
args: Any,
|
|
config_path: Path,
|
|
extra: Dict[str, Any] | None = None,
|
|
) -> None:
|
|
config: Dict[str, Any] = {}
|
|
for key, value in vars(args).items():
|
|
if isinstance(value, tuple):
|
|
config[key] = list(value)
|
|
elif isinstance(value, list):
|
|
config[key] = value
|
|
elif isinstance(value, (int, float, str, bool, type(None))):
|
|
config[key] = value
|
|
else:
|
|
config[key] = str(value)
|
|
if extra:
|
|
config.update(extra)
|
|
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|