Initial DeepHealthExpo next-token codebase

This commit is contained in:
2026-07-07 15:43:11 +08:00
commit e110e99f66
26 changed files with 10278 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Environments
.venv/
venv/
env/
# Training outputs
runs/
outputs/
checkpoints/
*.pt
*.pth
# Large/local data
ukb_*_data.npy
ukb_event_data.npy
ukb_other_info.npy
ukb_data.csv
ukb_basic_info.csv
ukb_train_eid.csv
ukb_val_eid.csv
ukb_test_eid.csv
UKB_*.csv
cate_types.csv
*.parquet
# OS/editor
.DS_Store
Thumbs.db
.vscode/

28
LICENSE Normal file
View File

@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Jiarui Li
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT INCLUDING NEGLIGENCE OR OTHERWISE ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

60
README.md Normal file
View File

@@ -0,0 +1,60 @@
# DeepHealthExpo
Next-token DeepHealth training code for disease-event sequence modeling with optional extra/exposure information.
This repository is a clean code-only extraction from the main DeepHealth project. It keeps the next-token training path and reusable model/data utilities, while excluding large UKB data files, trained checkpoints, result folders, and all-future training entry points.
## Included
- `train_next_step.py`: next-token / UTS training entry point.
- `dataset.py`: next-step event sequence dataset with unified extra-info tokens.
- `models.py`, `backbones.py`: DeepHealth Transformer backbone.
- `losses.py`, `readouts.py`, `targets.py`: training targets, losses, and readout utilities.
- `evaluate_auc.py`, `evaluate_token_auc.py`: next-token checkpoint evaluation utilities.
- `prepare_data.py`, `prepare_event_dates.py`, `event_date_utils.py`: data preparation helpers.
- `extra_info_types_*.txt`: reusable extra-info type selections.
## Not Included
The repository intentionally does not include raw or derived UKB arrays, split files, checkpoints, or run outputs.
Expected local data files for training normally include:
```text
ukb_event_data.npy
ukb_other_info.npy
ukb_basic_info.csv
ukb_train_eid.csv
ukb_val_eid.csv
ukb_test_eid.csv
cate_types.csv
```
`labels.csv` and `field_ids_enriched.csv` are included because they define the model vocabulary and preparation metadata.
## Example
```bash
python train_next_step.py \
--data_prefix ukb \
--labels_file labels.csv \
--extra_info_types_file extra_info_types_exposure_only.txt \
--target_mode uts \
--time_mode relative
```
For strict next-token Delphi-style training:
```bash
python train_next_step.py --target_mode delphi2m --readout_name token
```
## Exposure Modeling Direction
For onset-aligned environmental exposure parquet files, the first intended extension is single-stream event enhancement:
```text
disease event token + pre-onset exposure embedding -> same next-token Transformer
```
The key constraint is that a disease event's own pre-onset exposure must not be used to predict that same disease event.

328
backbones.py Normal file
View File

@@ -0,0 +1,328 @@
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class TimeRoPE(nn.Module):
def __init__(self, dim: int, base: float = 10000.0):
super().__init__()
assert dim % 2 == 0, "RoPE dim must be even"
self.dim = dim
# inv_freq is not trainable, but should move with device.
inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)
def precompute_cache(self, tau: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
t = tau.unsqueeze(-1) # (B, L, 1)
angles = t * self.inv_freq # (B, L, dim//2)
# Pre-expand for heads and interleave once (avoids N_layers repeats)
cos = angles.cos().unsqueeze(1).repeat_interleave(2, dim=-1)
sin = angles.sin().unsqueeze(1).repeat_interleave(2, dim=-1)
return cos, sin # (B, 1, L, dim)
@staticmethod
def _rotate_half(x: torch.Tensor) -> torch.Tensor:
"""Rotate pairs: ``[-x2, x1, -x4, x3, ...]``."""
x1 = x[..., 0::2]
x2 = x[..., 1::2]
return torch.stack((-x2, x1), dim=-1).flatten(-2)
@staticmethod
def apply_from_cache(
q: torch.Tensor,
k: torch.Tensor,
rope_cache: tuple[torch.Tensor, torch.Tensor],
) -> tuple[torch.Tensor, torch.Tensor]:
cos, sin = rope_cache # each (B, 1, L, dim)
q_rot = q * cos + TimeRoPE._rotate_half(q) * sin
k_rot = k * cos + TimeRoPE._rotate_half(k) * sin
return q_rot, k_rot
def forward(
self,
tau: torch.Tensor,
q: torch.Tensor,
k: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
cache = self.precompute_cache(tau)
return self.apply_from_cache(q, k, cache)
class GaussianRBFTimeBasis(nn.Module):
def __init__(
self,
n_bases: int = 16,
max_time_diff: float = 40.0,
):
super().__init__()
self.n_bases = n_bases
# Evenly spaced RBF centres for non-negative linear time differences.
# Causal masking enforces query_time >= key_time, so diff is >= 0.
centers = torch.linspace(0.0, max_time_diff, n_bases)
self.register_buffer("centers", centers,
persistent=False) # (n_bases,)
# Learnable log-widths (initialized to center spacing on linear scale).
init_width = max(max_time_diff / max(n_bases - 1, 1), 1e-3)
init_log_width = math.log(init_width)
self.log_widths = nn.Parameter(torch.full((n_bases,), init_log_width))
def precompute_cache(self, tau: torch.Tensor) -> torch.Tensor:
time_coord = tau.float() # (B, L)
# Pairwise signed difference: query_i - key_j.
diff = time_coord.unsqueeze(
2) - time_coord.unsqueeze(1) # (B, L_q, L_k)
# Gaussian RBF: exp(-0.5 * ((diff - c) / w)^2)
diff = diff.unsqueeze(-1) # (B, L, L, 1)
widths = self.log_widths.exp() # (n_bases,)
rbf_acts = torch.exp(
-0.5 * ((diff - self.centers) / widths).square()
# (B, L, L, n_bases)
)
return rbf_acts
class TemporalAttention(nn.Module):
def __init__(
self,
n_embd: int,
n_head: int,
n_rbf_bases: int = 16,
dropout: float = 0.0,
use_time_rope: bool = True,
use_rbf_bias: bool = True,
):
super().__init__()
assert n_embd % n_head == 0, "n_embd must be divisible by n_head"
self.n_head = n_head
self.d_head = n_embd // n_head
self.scale = 1.0 / math.sqrt(self.d_head)
self.use_time_rope = use_time_rope
self.use_rbf_bias = use_rbf_bias
# QKV projection (fused for efficiency)
self.qkv = nn.Linear(n_embd, 3 * n_embd, bias=False)
# Output projection
self.out_proj = nn.Linear(n_embd, n_embd, bias=False)
# Layer-specific projection from shared RBF basis activations to per-head attention bias.
self.rbf_proj = nn.Linear(n_rbf_bases, n_head, bias=False)
self.time_bias_scale = nn.Parameter(torch.tensor(0.0))
self.resid_drop = nn.Dropout(dropout)
self.reset_parameters()
def reset_parameters(self) -> None:
"""Match the previous version's GPT-style weight initialization."""
nn.init.normal_(self.qkv.weight, mean=0.0, std=0.02)
nn.init.normal_(self.out_proj.weight, mean=0.0, std=0.02)
nn.init.zeros_(self.rbf_proj.weight)
def forward(
self,
x: torch.Tensor,
rope_cache: tuple[torch.Tensor, torch.Tensor] | None = None,
rbf_cache: torch.Tensor | None = None,
attn_mask: torch.Tensor | None = None,
) -> torch.Tensor:
if self.use_time_rope:
assert rope_cache is not None, "rope_cache must be provided when use_time_rope is True"
if self.use_rbf_bias:
assert rbf_cache is not None, "rbf_cache must be provided when use_rbf_bias is True"
B, L, _ = x.shape
H, D = self.n_head, self.d_head
# --- QKV ----------------------------------------------------------
qkv = self.qkv(x).reshape(B, L, 3, H, D).permute(2, 0, 3, 1, 4)
q, k, v = qkv.unbind(0) # each (B, H, L, D)
# --- Apply RoPE (from shared cache) --------------------------------
if self.use_time_rope:
q, k = TimeRoPE.apply_from_cache(q, k, rope_cache)
# Build additive attention bias mask: time bias + causal/padding mask.
time_bias = None
if self.use_rbf_bias:
time_bias = self.rbf_proj(rbf_cache).permute(
0, 3, 1, 2) # (B, H, L, L)
time_bias = self.time_bias_scale.tanh() * time_bias
if time_bias is not None and attn_mask is not None:
attn_bias = time_bias + attn_mask.to(time_bias.dtype)
elif time_bias is not None:
attn_bias = time_bias
elif attn_mask is not None:
attn_bias = attn_mask
else:
attn_bias = None
out = F.scaled_dot_product_attention(
q,
k,
v,
attn_mask=attn_bias,
dropout_p=0.0,
is_causal=False,
scale=self.scale,
)
# --- Aggregate & project out --------------------------------------
out = out.transpose(1, 2).reshape(B, L, H * D)
return self.resid_drop(self.out_proj(out))
class SwiGLU(nn.Module):
def __init__(
self,
n_embd: int,
hidden_dim: int | None = None,
dropout: float = 0.0,
bias: bool = True,
):
super().__init__()
hidden_dim = hidden_dim if hidden_dim is not None else int(
n_embd * 2.5)
self.w1 = nn.Linear(n_embd, hidden_dim, bias=bias) # gate path
self.w2 = nn.Linear(n_embd, hidden_dim, bias=bias) # value path
# output projection
self.w3 = nn.Linear(hidden_dim, n_embd, bias=bias)
self.drop = nn.Dropout(dropout)
self.reset_parameters()
def reset_parameters(self) -> None:
"""GPT-style parameter initialization for MLP paths."""
nn.init.normal_(self.w1.weight, mean=0.0, std=0.02)
nn.init.normal_(self.w2.weight, mean=0.0, std=0.02)
nn.init.normal_(self.w3.weight, mean=0.0, std=0.02)
if self.w1.bias is not None:
nn.init.zeros_(self.w1.bias)
nn.init.zeros_(self.w2.bias)
nn.init.zeros_(self.w3.bias)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""``(B, L, n_embd) -> (B, L, n_embd)``."""
return self.drop(self.w3(F.silu(self.w1(x)) * self.w2(x)))
class GPTBlock(nn.Module):
def __init__(
self,
n_embd: int,
n_head: int,
attn_dropout: float = 0.0,
mlp_dropout: float = 0.0,
use_time_rope: bool = False,
use_rbf_bias: bool = False,
n_rbf_bases: int = 16,
):
super().__init__()
self.attn = TemporalAttention(
n_embd=n_embd,
n_head=n_head,
n_rbf_bases=n_rbf_bases,
dropout=attn_dropout,
use_time_rope=use_time_rope,
use_rbf_bias=use_rbf_bias,
)
self.mlp = SwiGLU(n_embd=n_embd, dropout=mlp_dropout)
self.ln1 = nn.LayerNorm(n_embd)
self.ln2 = nn.LayerNorm(n_embd)
def forward(
self,
x: torch.Tensor,
rope_cache: tuple[torch.Tensor, torch.Tensor] | None = None,
rbf_cache: torch.Tensor | None = None,
attn_mask: torch.Tensor | None = None,
) -> torch.Tensor:
x = x + self.attn(self.ln1(x), rope_cache, rbf_cache, attn_mask)
x = x + self.mlp(self.ln2(x))
return x
class TokenAutoDiscretization(nn.Module):
def __init__(
self,
n_cont_types: int,
n_bins: int,
n_embd: int,
):
super().__init__()
if n_cont_types <= 0:
raise ValueError(f"n_cont_types must be > 0, got {n_cont_types}")
if n_bins <= 1:
raise ValueError(f"n_bins must be > 1, got {n_bins}")
if n_embd <= 0:
raise ValueError(f"n_embd must be > 0, got {n_embd}")
self.n_cont_types = n_cont_types
self.n_bins = n_bins
self.n_embd = n_embd
self.weight = nn.Parameter(torch.empty(n_cont_types, n_bins))
self.bias = nn.Parameter(torch.empty(n_cont_types, n_bins))
self.bin_emb = nn.Parameter(torch.empty(n_cont_types, n_bins, n_embd))
self.reset_parameters()
def reset_parameters(self) -> None:
nn.init.normal_(self.weight, mean=0.0, std=0.02)
nn.init.zeros_(self.bias)
nn.init.normal_(self.bin_emb, mean=0.0, std=0.02)
def forward(
self,
cont_type_idx: torch.LongTensor, # (N,)
value: torch.Tensor, # (N,)
) -> torch.Tensor:
if cont_type_idx.dim() != 1:
raise ValueError(
f"cont_type_idx must be 1D, got {tuple(cont_type_idx.shape)}"
)
if value.dim() != 1:
raise ValueError(f"value must be 1D, got {tuple(value.shape)}")
if cont_type_idx.numel() != value.numel():
raise ValueError("cont_type_idx and value must have the same length")
w = self.weight[cont_type_idx] # (N, n_bins)
b = self.bias[cont_type_idx] # (N, n_bins)
e = self.bin_emb[cont_type_idx] # (N, n_bins, D)
logits = value.unsqueeze(-1) * w + b
probs = torch.softmax(logits, dim=-1)
return torch.einsum("nb,nbd->nd", probs, e)
class AgeSinusoidalEncoding(nn.Module):
def __init__(self, embedding_dim: int):
super().__init__()
if embedding_dim % 2 != 0:
raise ValueError(
f"Embedding dimension must be an even number, but got {embedding_dim}")
self.embedding_dim = embedding_dim
i = torch.arange(0, self.embedding_dim, 2, dtype=torch.float32)
divisor = torch.pow(10000, i / self.embedding_dim)
self.register_buffer('divisor', divisor)
self.linear = nn.Linear(embedding_dim, embedding_dim, bias=False)
def forward(self, t: torch.Tensor) -> torch.Tensor:
t_years = t
# Broadcast (B, L, 1) against (1, 1, D/2) to get (B, L, D/2)
args = t_years.unsqueeze(-1) / self.divisor.view(1, 1, -1)
# Interleave cos and sin along the last dimension
output = torch.zeros(t.shape[0], t.shape[1],
self.embedding_dim, device=t.device)
output[:, :, 0::2] = torch.cos(args)
output[:, :, 1::2] = torch.sin(args)
output = self.linear(output)
return output

674
dataset.py Normal file
View File

@@ -0,0 +1,674 @@
# dataset.py
from __future__ import annotations
from typing import Dict, Iterable, List, Literal, Optional, Tuple
import numpy as np
import pandas as pd
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import Dataset
from targets import (
CHECKUP_IDX,
DAYS_PER_YEAR,
NO_EVENT_IDX,
PAD_IDX,
build_all_targets,
)
ONE_DAY_YEARS = 1.0 / DAYS_PER_YEAR
def load_label_vocab(
labels_file: str,
include_no_event: bool = True,
) -> Tuple[Dict[str, int], Dict[int, str]]:
label_id_to_code: Dict[int, str] = {
PAD_IDX: "<PAD>",
CHECKUP_IDX: "<CHECKUP>",
}
if include_no_event:
label_id_to_code[NO_EVENT_IDX] = "<NO_EVENT>"
offset = NO_EVENT_IDX + 1 if include_no_event else CHECKUP_IDX + 1
label_code_to_id: Dict[str, int] = {}
with open(labels_file, encoding="utf-8") as f:
for i, line in enumerate(f):
parts = line.strip().split()
if not parts:
continue
idx = offset + i
code = parts[0]
label_code_to_id[code] = idx
label_id_to_code[idx] = code
return label_code_to_id, label_id_to_code
def _insert_gap_no_event_tokens(
times_days: np.ndarray,
labels: np.ndarray,
interval_years: float = 5.0,
) -> Tuple[np.ndarray, np.ndarray]:
if len(times_days) < 2:
return times_days, labels
step_days = interval_years * DAYS_PER_YEAR
unique_times = np.unique(times_days.astype(np.float64))
extra_times: List[float] = []
for i in range(len(unique_times) - 1):
t_left = float(unique_times[i])
t_right = float(unique_times[i + 1])
if t_right - t_left <= step_days:
continue
first = np.ceil((t_left + 1e-6) / step_days) * step_days
t = first
while t < t_right - 1e-6:
extra_times.append(t)
t += step_days
if not extra_times:
return times_days, labels
extra_arr = np.array(extra_times, dtype=np.float32)
no_event_labels = np.full(len(extra_arr), NO_EVENT_IDX, dtype=np.int64)
all_times = np.concatenate([times_days.astype(np.float32), extra_arr])
all_labels = np.concatenate([labels.astype(np.int64), no_event_labels])
order = np.lexsort((all_labels, all_times))
return all_times[order], all_labels[order]
class _ExpoBaseDataset(Dataset):
def __init__(
self,
data_prefix: str = "ukb",
labels_file: str = "labels.csv",
no_event_interval_years: float = 5.0,
include_no_event_in_uts_target: bool = False,
extra_info_types: Iterable[int] | None = None,
) -> None:
self.data_prefix = data_prefix
self.labels_file = labels_file
self.no_event_interval_years = float(no_event_interval_years)
self.include_no_event_in_uts_target = bool(include_no_event_in_uts_target)
self.requested_extra_info_types = (
None
if extra_info_types is None
else list(dict.fromkeys(int(t) for t in extra_info_types))
)
self.label_code_to_id, self.label_id_to_code = load_label_vocab(
labels_file,
include_no_event=True,
)
event_data = np.load(f"{data_prefix}_event_data.npy")
if event_data.ndim != 2 or event_data.shape[1] < 3:
raise ValueError(f"event_data must have shape (N, 3+), got {event_data.shape}")
event_data = event_data[:, :3].copy()
order = np.lexsort((event_data[:, 2], event_data[:, 1], event_data[:, 0]))
self.event_data = event_data[order]
basic_table = pd.read_csv(f"{data_prefix}_basic_info.csv", index_col=0)
other_info = np.load(f"{data_prefix}_other_info.npy")
if other_info.ndim != 2 or other_info.shape[1] != 5:
raise ValueError(
f"other_info must have shape (N, 5), got {other_info.shape}"
)
cate_types = pd.read_csv("cate_types.csv")
required_cate_cols = {"type", "name", "n_categories"}
missing_cate_cols = required_cate_cols - set(cate_types.columns)
if missing_cate_cols:
raise ValueError(
f"cate_types.csv is missing columns: {sorted(missing_cate_cols)}"
)
basic_table.index = basic_table.index.astype(np.int64)
unique_eids = np.unique(self.event_data[:, 0].astype(np.int64))
basic_table = basic_table.loc[unique_eids]
self._prepare_sex(basic_table, unique_eids)
self._prepare_other_info(other_info, cate_types, unique_eids)
max_id_in_vocab = max(self.label_id_to_code.keys())
max_id_in_data = int(self.event_data[:, 2].max()) if len(self.event_data) > 0 else 0
max_id_in_data += 1
self.vocab_size = max(max_id_in_vocab, max_id_in_data) + 1
if not self.include_no_event_in_uts_target:
self.ignored_uts_target_ids = {PAD_IDX, CHECKUP_IDX, NO_EVENT_IDX}
else:
self.ignored_uts_target_ids = {PAD_IDX, CHECKUP_IDX}
def _prepare_sex(self, basic_table: pd.DataFrame, unique_eids: np.ndarray) -> None:
sex_values = pd.to_numeric(basic_table["sex"], errors="coerce").to_numpy()
if np.isnan(sex_values).any():
raise ValueError("sex column contains missing or non-numeric values")
sex_values = sex_values.astype(np.int64)
sex_unique = np.unique(sex_values)
if np.all(np.isin(sex_unique, [0, 1])):
sex01 = sex_values
elif np.all(np.isin(sex_unique, [1, 2])):
sex01 = sex_values - 1
else:
raise ValueError(
f"Unexpected sex values: {sex_unique.tolist()}. Expected {{0,1}} or {{1,2}}."
)
self.sex_mapping = {int(eid): int(s) for eid, s in zip(unique_eids, sex01)}
def _prepare_other_info(
self,
other_info: np.ndarray,
cate_types: pd.DataFrame,
unique_eids: np.ndarray,
) -> None:
other_info = other_info.copy()
other_info[:, 0] = other_info[:, 0].astype(np.int64)
other_info[:, 1] = other_info[:, 1].astype(np.int64)
other_info[:, 3] = other_info[:, 3].astype(np.int64)
available_types = sorted(
int(t) for t in np.unique(other_info[:, 1]) if int(t) > 0
)
if self.requested_extra_info_types is None:
selected_types = available_types
else:
selected_types = self.requested_extra_info_types
missing = sorted(set(selected_types) - set(available_types))
if missing:
raise ValueError(f"Requested extra_info_types not found: {missing}")
keep = np.isin(other_info[:, 0].astype(np.int64), unique_eids)
keep &= np.isin(other_info[:, 1].astype(np.int64), selected_types)
other_info = other_info[keep]
cate_counts = {
int(row["type"]): int(row["n_categories"])
for _, row in cate_types.iterrows()
}
cate_offsets: Dict[int, int] = {}
next_offset = 0
for type_id in selected_types:
if type_id in cate_counts:
cate_offsets[type_id] = next_offset
next_offset += cate_counts[type_id]
kinds = other_info[:, 3].astype(np.int64)
types = other_info[:, 1].astype(np.int64)
cate_rows = kinds == 2
for type_id in np.unique(types[cate_rows]):
type_id = int(type_id)
if type_id not in cate_offsets:
raise ValueError(
f"type {type_id} appears categorical but is missing from cate_types.csv"
)
row_mask = cate_rows & (types == type_id)
local_value = other_info[row_mask, 2].astype(np.int64)
other_info[row_mask, 2] = local_value + cate_offsets[type_id]
cont_type_ids = [
int(t)
for t in selected_types
if np.any((types == int(t)) & (kinds == 1))
]
self.extra_info_types = selected_types
self.cate_type_offsets = cate_offsets
self.n_types = (max(selected_types) + 1) if selected_types else 1
self.cont_type_ids = cont_type_ids
self.n_cont_types = len(cont_type_ids)
self.n_categories = next_offset + 1
order = np.lexsort((other_info[:, 4], other_info[:, 1], other_info[:, 0]))
other_info = other_info[order]
self.other_info_by_eid: Dict[int, Dict[str, np.ndarray]] = {}
for eid in unique_eids.astype(np.int64):
self.other_info_by_eid[int(eid)] = {
"other_type": np.zeros(0, dtype=np.int64),
"other_value": np.zeros(0, dtype=np.float32),
"other_value_kind": np.zeros(0, dtype=np.int64),
"other_time": np.zeros(0, dtype=np.float32),
}
if len(other_info) == 0:
return
eids, starts = np.unique(other_info[:, 0].astype(np.int64), return_index=True)
ends = np.concatenate([starts[1:], [len(other_info)]])
for eid_raw, start, end in zip(eids, starts, ends):
rows = other_info[start:end]
self.other_info_by_eid[int(eid_raw)] = {
"other_type": rows[:, 1].astype(np.int64),
"other_value": rows[:, 2].astype(np.float32),
"other_value_kind": rows[:, 3].astype(np.int64),
"other_time": (rows[:, 4].astype(np.float32) / DAYS_PER_YEAR),
}
def _iter_patient_events(
self,
*,
impute_no_event_gaps: bool,
) -> Iterable[tuple[int, np.ndarray, np.ndarray]]:
unique_eids, starts = np.unique(self.event_data[:, 0], return_index=True)
ends = np.concatenate([starts[1:], [len(self.event_data)]])
for eid_raw, start, end in zip(unique_eids, starts, ends):
eid = int(eid_raw)
rows = self.event_data[start:end]
times_days_raw = rows[:, 1].astype(np.float32)
labels_raw = rows[:, 2].astype(np.int64)
if len(labels_raw) == 0:
yield eid, times_days_raw, labels_raw
continue
labels_raw = np.where(labels_raw >= NO_EVENT_IDX, labels_raw + 1, labels_raw)
if not impute_no_event_gaps:
yield eid, times_days_raw, labels_raw
continue
times_days, labels = _insert_gap_no_event_tokens(
times_days_raw,
labels_raw,
interval_years=self.no_event_interval_years,
)
yield eid, times_days, labels
def _split_features(self, eid: int) -> Optional[Dict]:
other_info = self.other_info_by_eid.get(eid)
if other_info is None:
return None
return {
"sex": self.sex_mapping[eid],
**other_info,
}
class NextStepHealthDataset(_ExpoBaseDataset):
"""
Dataset for next-token and next-time-point losses with unified other-info
tokens.
Returned targets cover both:
- Delphi2MLoss: target_event_seq, target_time_seq
- UniqueTimeSetExponentialLoss: readout_mask, target_dt_unique, target_multi_hot
"""
CACHE_VERSION = 3
def __init__(
self,
data_prefix: str = "ukb",
labels_file: str = "labels.csv",
no_event_interval_years: float = 5.0,
include_no_event_in_uts_target: bool = False,
extra_info_types: Iterable[int] | None = None,
) -> None:
super().__init__(
data_prefix=data_prefix,
labels_file=labels_file,
no_event_interval_years=no_event_interval_years,
include_no_event_in_uts_target=include_no_event_in_uts_target,
extra_info_types=extra_info_types,
)
self.samples: List[Dict] = []
for eid, times_days, labels in self._iter_patient_events(
impute_no_event_gaps=True,
):
if len(labels) < 2:
continue
features = self._split_features(eid)
if features is None:
continue
target_pack = build_all_targets(
labels=labels,
times_days=times_days,
vocab_size=self.vocab_size,
ignored_uts_target_ids=self.ignored_uts_target_ids,
require_sorted=True,
)
self.samples.append({
"eid": eid,
"event_seq": target_pack.next_token.input_events,
"time_seq": target_pack.next_token.input_times_years,
"target_event_seq": target_pack.next_token.target_events,
"target_time_seq": target_pack.next_token.target_times_years,
"readout_mask": target_pack.unique_time_set.readout_mask,
"target_dt_unique": target_pack.unique_time_set.target_dt_unique,
"target_multi_hot": target_pack.unique_time_set.target_multi_hot,
**features,
})
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> Dict:
s = self.samples[idx]
return {
"event_seq": torch.from_numpy(s["event_seq"]).long(),
"time_seq": torch.from_numpy(s["time_seq"]).float(),
"sex": torch.tensor(s["sex"], dtype=torch.long),
"other_type": torch.from_numpy(s["other_type"]).long(),
"other_value": torch.from_numpy(s["other_value"]).float(),
"other_value_kind": torch.from_numpy(s["other_value_kind"]).long(),
"other_time": torch.from_numpy(s["other_time"]).float(),
"target_event_seq": torch.from_numpy(s["target_event_seq"]).long(),
"target_time_seq": torch.from_numpy(s["target_time_seq"]).float(),
"readout_mask": torch.from_numpy(s["readout_mask"]).bool(),
"target_dt_unique": torch.from_numpy(s["target_dt_unique"]).float(),
"target_multi_hot": torch.from_numpy(s["target_multi_hot"]).bool(),
}
class AllFutureHealthDataset(_ExpoBaseDataset):
"""
Dataset with unified other-info tokens and DeepHealthV2-style all-future
targets.
Train samples one query time per patient at each __getitem__ call.
Valid/test use random-but-fixed query points. For each patient with N real
disease events, N - 2 query points are sampled from the eligible observed
time range, with at least one future event after every query.
"""
CACHE_VERSION = 5
def __init__(
self,
data_prefix: str = "ukb",
labels_file: str = "labels.csv",
split: Literal["train", "valid", "test"] = "train",
no_event_interval_years: float = 5.0,
include_no_event_in_uts_target: bool = False,
min_history_events: int = 1,
min_future_events: int = 1,
validation_query_seed: int = 42,
extra_info_types: Iterable[int] | None = None,
) -> None:
if split not in {"train", "valid", "test"}:
raise ValueError(f"split must be train/valid/test, got {split!r}")
super().__init__(
data_prefix=data_prefix,
labels_file=labels_file,
no_event_interval_years=no_event_interval_years,
include_no_event_in_uts_target=include_no_event_in_uts_target,
extra_info_types=extra_info_types,
)
self.split = split
self.min_history_events = int(min_history_events)
self.min_future_events = int(min_future_events)
self.validation_query_seed = int(validation_query_seed)
self.patients: List[Dict] = []
self.valid_queries: List[Tuple[int, float]] = []
validation_rng = None
if split in {"valid", "test"}:
split_offset = 0 if split == "valid" else 1_000_003
validation_rng = np.random.RandomState(self.validation_query_seed + split_offset)
for eid, times_days, labels in self._iter_patient_events(
impute_no_event_gaps=False,
):
times_years = (times_days / DAYS_PER_YEAR).astype(np.float32)
unique_times = np.unique(times_years)
if len(labels) < 2 or len(unique_times) < 2:
continue
features = self._split_features(eid)
if features is None:
continue
patient = {
"eid": eid,
"times": times_years,
"labels": labels.astype(np.int64),
"t_obs": float(times_years.max()),
**features,
}
pidx = len(self.patients)
self.patients.append(patient)
if split in {"valid", "test"}:
if validation_rng is None:
raise RuntimeError("validation_rng was not initialized")
self.valid_queries.extend(
(pidx, t_query)
for t_query in self._sample_fixed_validation_queries(
patient,
validation_rng,
)
)
if split in {"valid", "test"} and not self.valid_queries:
raise ValueError("No random-but-fixed validation query points were built.")
def _is_valid_query(self, patient: Dict, t_query: float) -> bool:
times = patient["times"]
labels = patient["labels"]
real_event_mask = ~np.isin(
labels,
np.array([PAD_IDX, CHECKUP_IDX, NO_EVENT_IDX], dtype=np.int64),
)
n_hist = int((times <= t_query).sum())
n_future = int(((times > t_query) & real_event_mask).sum())
return (
n_hist >= self.min_history_events
and n_future >= self.min_future_events
and patient["t_obs"] > t_query
)
def _sample_fixed_validation_queries(
self,
patient: Dict,
rng: np.random.RandomState,
) -> List[float]:
times = np.asarray(patient["times"], dtype=np.float32)
labels = np.asarray(patient["labels"], dtype=np.int64)
real_event_mask = ~np.isin(
labels,
np.array([PAD_IDX, CHECKUP_IDX, NO_EVENT_IDX], dtype=np.int64),
)
real_times = np.sort(times[real_event_mask].astype(np.float32, copy=False))
n_real_events = int(real_times.size)
n_queries = max(0, n_real_events - 2)
if n_queries == 0:
return []
min_hist = int(self.min_history_events)
min_future = int(self.min_future_events)
if n_real_events < min_hist + min_future:
return []
left = float(real_times[min_hist - 1])
right_event_time = float(real_times[n_real_events - min_future])
right = np.nextafter(np.float32(right_event_time), np.float32(-np.inf))
if not np.isfinite(left) or not np.isfinite(right) or float(right) <= left:
return []
queries: List[float] = []
max_attempts = max(100, n_queries * 50)
for _ in range(max_attempts):
if len(queries) >= n_queries:
break
t_query = float(rng.uniform(left, float(right)))
if self._is_valid_query(patient, t_query):
queries.append(t_query)
return queries
def _sample_train_query(self, patient: Dict) -> float:
unique_times = np.unique(patient["times"])
if len(unique_times) < 2:
raise RuntimeError("Training patient has fewer than two unique times.")
j = np.random.randint(1, len(unique_times))
left = float(unique_times[j - 1])
right = float(unique_times[j])
if right - left <= ONE_DAY_YEARS:
t_query = right - ONE_DAY_YEARS
else:
t_query = np.random.uniform(left, right - ONE_DAY_YEARS)
if not self._is_valid_query(patient, t_query):
t_query = right - 1e-6
return float(t_query)
def _build_item(self, patient: Dict, t_query: float) -> Dict:
times = patient["times"]
labels = patient["labels"]
hist = times <= t_query
fut = times > t_query
return {
"event_seq": torch.from_numpy(labels[hist]).long(),
"time_seq": torch.from_numpy(times[hist]).float(),
"t_query": torch.tensor(t_query, dtype=torch.float32),
"future_targets": torch.from_numpy(labels[fut]).long(),
"future_dt": torch.from_numpy(times[fut] - np.float32(t_query)).float(),
"exposure": torch.tensor(np.float32(patient["t_obs"] - t_query), dtype=torch.float32),
"sex": torch.tensor(patient["sex"], dtype=torch.long),
"other_type": torch.from_numpy(patient["other_type"]).long(),
"other_value": torch.from_numpy(patient["other_value"]).float(),
"other_value_kind": torch.from_numpy(patient["other_value_kind"]).long(),
"other_time": torch.from_numpy(patient["other_time"]).float(),
}
def __len__(self) -> int:
if self.split == "train":
return len(self.patients)
return len(self.valid_queries)
def __getitem__(self, idx: int) -> Dict:
if self.split == "train":
patient = self.patients[idx]
t_query = self._sample_train_query(patient)
else:
pidx, t_query = self.valid_queries[idx]
patient = self.patients[pidx]
return self._build_item(patient, t_query)
def _collate_common_static(batch: List[Dict]) -> Dict:
return {
"sex": torch.stack([s["sex"] for s in batch]),
"other_type": pad_sequence(
[s["other_type"] for s in batch],
batch_first=True,
padding_value=0,
),
"other_value": pad_sequence(
[s["other_value"] for s in batch],
batch_first=True,
padding_value=0.0,
),
"other_value_kind": pad_sequence(
[s["other_value_kind"] for s in batch],
batch_first=True,
padding_value=0,
),
"other_time": pad_sequence(
[s["other_time"] for s in batch],
batch_first=True,
padding_value=0.0,
),
}
def next_step_collate_fn(batch: List[Dict]) -> Dict:
event_seq = pad_sequence(
[s["event_seq"] for s in batch],
batch_first=True,
padding_value=PAD_IDX,
)
time_seq = pad_sequence(
[s["time_seq"] for s in batch],
batch_first=True,
padding_value=0.0,
)
target_event_seq = pad_sequence(
[s["target_event_seq"] for s in batch],
batch_first=True,
padding_value=PAD_IDX,
)
target_time_seq = pad_sequence(
[s["target_time_seq"] for s in batch],
batch_first=True,
padding_value=0.0,
)
readout_mask = pad_sequence(
[s["readout_mask"] for s in batch],
batch_first=True,
padding_value=False,
)
target_dt_unique = pad_sequence(
[s["target_dt_unique"] for s in batch],
batch_first=True,
padding_value=0.0,
)
target_multi_hot = pad_sequence(
[s["target_multi_hot"] for s in batch],
batch_first=True,
padding_value=False,
)
out = {
"event_seq": event_seq,
"time_seq": time_seq,
"padding_mask": event_seq > PAD_IDX,
"target_event_seq": target_event_seq,
"target_time_seq": target_time_seq,
"readout_mask": readout_mask,
"target_dt_unique": target_dt_unique,
"target_multi_hot": target_multi_hot,
}
out.update(_collate_common_static(batch))
return out
def all_future_collate_fn(batch: List[Dict]) -> Dict:
event_seq = pad_sequence(
[s["event_seq"] for s in batch],
batch_first=True,
padding_value=PAD_IDX,
)
time_seq = pad_sequence(
[s["time_seq"] for s in batch],
batch_first=True,
padding_value=0.0,
)
future_targets = pad_sequence(
[s["future_targets"] for s in batch],
batch_first=True,
padding_value=PAD_IDX,
)
future_dt = pad_sequence(
[s["future_dt"] for s in batch],
batch_first=True,
padding_value=0.0,
)
out = {
"event_seq": event_seq,
"time_seq": time_seq,
"padding_mask": event_seq > PAD_IDX,
"t_query": torch.stack([s["t_query"] for s in batch]),
"future_targets": future_targets,
"future_dt": future_dt,
"exposure": torch.stack([s["exposure"] for s in batch]),
}
out.update(_collate_common_static(batch))
return out
HealthDataset = NextStepHealthDataset
collate_fn = next_step_collate_fn

163
eval_data.py Normal file
View File

@@ -0,0 +1,163 @@
from __future__ import annotations
from typing import Any, Dict, Iterable, List
import numpy as np
import torch
from torch.nn.utils.rnn import pad_sequence
from dataset import AllFutureHealthDataset, HealthDataset
from targets import PAD_IDX
class AllFutureSequenceEvalDataset:
"""
Eval-only sequence view for all-future checkpoints.
All-future training uses the observed history, including CHECKUP state
tokens, without reusing the next-step view that contains imputed
<NO_EVENT> gap tokens.
"""
def __init__(
self,
data_prefix: str,
labels_file: str,
min_history_events: int = 1,
min_future_events: int = 1,
extra_info_types: Iterable[int] | None = None,
) -> None:
base = AllFutureHealthDataset(
data_prefix=data_prefix,
labels_file=labels_file,
split="train",
min_history_events=min_history_events,
min_future_events=min_future_events,
extra_info_types=extra_info_types,
)
self.base = base
self.label_code_to_id = base.label_code_to_id
self.label_id_to_code = base.label_id_to_code
self.vocab_size = base.vocab_size
self.n_types = base.n_types
self.n_cont_types = base.n_cont_types
self.n_categories = base.n_categories
self.cont_type_ids = base.cont_type_ids
self.extra_info_types = base.extra_info_types
self.samples: List[Dict[str, Any]] = []
for patient in base.patients:
labels = np.asarray(patient["labels"], dtype=np.int64)
times = np.asarray(patient["times"], dtype=np.float32)
if labels.size < 2:
continue
input_len = int(labels.size - 1)
self.samples.append(
{
"eid": int(patient["eid"]),
"event_seq": labels[:-1],
"time_seq": times[:-1],
"target_event_seq": labels[1:],
"target_time_seq": times[1:],
"readout_mask": np.ones(input_len, dtype=bool),
"sex": int(patient["sex"]),
"other_type": np.asarray(patient["other_type"], dtype=np.int64),
"other_value": np.asarray(patient["other_value"], dtype=np.float32),
"other_value_kind": np.asarray(patient["other_value_kind"], dtype=np.int64),
"other_time": np.asarray(patient["other_time"], dtype=np.float32),
}
)
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
s = self.samples[idx]
return {
"event_seq": torch.from_numpy(s["event_seq"]).long(),
"time_seq": torch.from_numpy(s["time_seq"]).float(),
"target_event_seq": torch.from_numpy(s["target_event_seq"]).long(),
"target_time_seq": torch.from_numpy(s["target_time_seq"]).float(),
"readout_mask": torch.from_numpy(s["readout_mask"]).bool(),
"sex": torch.tensor(s["sex"], dtype=torch.long),
"other_type": torch.from_numpy(s["other_type"]).long(),
"other_value": torch.from_numpy(s["other_value"]).float(),
"other_value_kind": torch.from_numpy(s["other_value_kind"]).long(),
"other_time": torch.from_numpy(s["other_time"]).float(),
}
def load_sequence_eval_dataset(
*,
model_target_mode: str,
data_prefix: str,
labels_file: str,
no_event_interval_years: float,
include_no_event_in_uts_target: bool,
min_history_events: int,
min_future_events: int,
extra_info_types: Iterable[int] | None,
):
mode = str(model_target_mode).lower()
if mode == "next_token":
return HealthDataset(
data_prefix=data_prefix,
labels_file=labels_file,
no_event_interval_years=no_event_interval_years,
include_no_event_in_uts_target=include_no_event_in_uts_target,
extra_info_types=extra_info_types,
)
if mode == "all_future":
return AllFutureSequenceEvalDataset(
data_prefix=data_prefix,
labels_file=labels_file,
min_history_events=min_history_events,
min_future_events=min_future_events,
extra_info_types=extra_info_types,
)
raise ValueError(f"Unknown model_target_mode: {model_target_mode!r}")
def sequence_eval_collate_fn(batch: List[Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
event_seq = pad_sequence(
[s["event_seq"] for s in batch], batch_first=True, padding_value=PAD_IDX
)
time_seq = pad_sequence(
[s["time_seq"] for s in batch], batch_first=True, padding_value=0.0
)
target_event_seq = pad_sequence(
[s["target_event_seq"] for s in batch], batch_first=True, padding_value=PAD_IDX
)
target_time_seq = pad_sequence(
[s["target_time_seq"] for s in batch], batch_first=True, padding_value=0.0
)
readout_mask = pad_sequence(
[s["readout_mask"] for s in batch], batch_first=True, padding_value=False
)
other_type = pad_sequence(
[s["other_type"] for s in batch], batch_first=True, padding_value=0
)
other_value = pad_sequence(
[s["other_value"] for s in batch], batch_first=True, padding_value=0.0
)
other_value_kind = pad_sequence(
[s["other_value_kind"] for s in batch], batch_first=True, padding_value=0
)
other_time = pad_sequence(
[s["other_time"] for s in batch], batch_first=True, padding_value=0.0
)
return {
"event_seq": event_seq,
"time_seq": time_seq,
"padding_mask": event_seq > PAD_IDX,
"target_event_seq": target_event_seq,
"target_time_seq": target_time_seq,
"readout_mask": readout_mask,
"sex": torch.stack([s["sex"] for s in batch]),
"other_type": other_type,
"other_value": other_value,
"other_value_kind": other_value_kind,
"other_time": other_time,
}

1423
evaluate_auc.py Normal file

File diff suppressed because it is too large Load Diff

7
evaluate_token_auc.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from evaluate_auc import main
if __name__ == "__main__":
main()

104
event_date_utils.py Normal file
View File

@@ -0,0 +1,104 @@
"""Read and query calendar-dated disease-event arrays from prepare_event_dates.py."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import numpy as np
import pandas as pd
REQUIRED_FIELDS = {"eid", "event_date", "token"}
def load_event_dates(path: str | Path) -> np.ndarray:
"""Load and validate the structured ``.npy`` event array."""
events = np.load(path)
if events.dtype.names is None or not REQUIRED_FIELDS.issubset(events.dtype.names):
raise ValueError(
"Expected a structured .npy with eid, event_date, token fields. "
"Create it with prepare_event_dates.py."
)
return events
def load_token_labels(labels_file: str | Path) -> dict[int, str]:
"""Load token -> human-readable code using the project label convention."""
labels = {1: "CHECKUP"}
with Path(labels_file).open(encoding="utf-8") as handle:
for index, line in enumerate(handle):
code = line.strip().split(" ", maxsplit=1)[0]
if code:
labels[index + 2] = code
return labels
@dataclass
class EventDateIndex:
"""Small in-memory query wrapper for exposure-linkage and cohort scripts."""
events: np.ndarray
token_labels: dict[int, str] | None = None
@classmethod
def from_files(
cls,
event_file: str | Path,
labels_file: str | Path | None = None,
) -> "EventDateIndex":
labels = load_token_labels(labels_file) if labels_file is not None else None
return cls(load_event_dates(event_file), labels)
def to_frame(self, events: np.ndarray | None = None) -> pd.DataFrame:
"""Convert records to a convenient, calendar-dated DataFrame."""
data = self.events if events is None else events
frame = pd.DataFrame(
{
"eid": data["eid"].astype("int64"),
"event_date": pd.to_datetime(data["event_date"]),
"token": data["token"].astype("int32"),
}
)
if self.token_labels is not None:
frame["label_code"] = frame["token"].map(self.token_labels).fillna("UNKNOWN")
return frame.sort_values(["eid", "event_date", "token"], kind="stable").reset_index(drop=True)
def for_eid(self, eid: int) -> pd.DataFrame:
"""Return every stored disease/death event for one participant."""
return self.to_frame(self.events[self.events["eid"] == int(eid)])
def between(
self,
start: str | pd.Timestamp,
end: str | pd.Timestamp,
*,
eids: Iterable[int] | None = None,
tokens: Iterable[int] | None = None,
) -> pd.DataFrame:
"""Query events in an inclusive calendar-date interval."""
start_day = np.datetime64(pd.Timestamp(start).date(), "D")
end_day = np.datetime64(pd.Timestamp(end).date(), "D")
mask = (self.events["event_date"] >= start_day) & (self.events["event_date"] <= end_day)
if eids is not None:
mask &= np.isin(self.events["eid"], list(eids))
if tokens is not None:
mask &= np.isin(self.events["token"], list(tokens))
return self.to_frame(self.events[mask])
def anchors_before(self, eid: int, date: str | pd.Timestamp) -> pd.DataFrame:
"""Return a participant's event history strictly before an exposure anchor."""
day = np.datetime64(pd.Timestamp(date).date(), "D")
mask = (self.events["eid"] == int(eid)) & (self.events["event_date"] < day)
return self.to_frame(self.events[mask])
def first_event(self, token: int) -> pd.DataFrame:
"""Return each participant's first date for a requested token."""
selected = self.events[self.events["token"] == int(token)]
# Arrays produced by prepare_event_dates.py are already deduplicated;
# sorting makes this safe for externally produced compatible arrays too.
order = np.lexsort((selected["event_date"], selected["eid"]))
selected = selected[order]
_, first = np.unique(selected["eid"], return_index=True)
return self.to_frame(selected[first])

268
extra_info_types_all.txt Normal file
View File

@@ -0,0 +1,268 @@
# All other-info variables (field_type=1 and field_type=2)
# Generated from field_ids_enriched.csv using prepare_data.py other-info type ordering.
# Format: <extra_info_type_id> # <var_name> | <full_name>
1 # waist_circumference | Waist circumference
2 # hip_circumference | Hip circumference
3 # standing_height | Standing height
4 # fasting_time | Fasting time
5 # pulse_rate | Pulse rate automated reading
6 # dbp | Diastolic blood pressure automated reading
7 # sbp | Systolic blood pressure automated reading
8 # fev1_best | Forced expiratory volume in 1-second (FEV1) Best measure
9 # fvc_best | Forced vital capacity (FVC) Best measure
10 # fev1_fvc_ratio | FEV1/ FVC ratio Z-score
11 # bmi | Body mass index (BMI)
12 # WBC | White blood cell (leukocyte) count
13 # RBC | Red blood cell (erythrocyte) count
14 # hemoglobin | Haemoglobin concentration
15 # hematocrit | Haematocrit percentage
16 # MCV | Mean corpuscular volume
17 # MCH | Mean corpuscular haemoglobin
18 # MCHC | Mean corpuscular haemoglobin concentration
19 # Pc | Platelet count
20 # MPV | Mean platelet (thrombocyte) volume
21 # LymC | Lymphocyte count
22 # MonC | Monocyte count
23 # NeuC | Neutrophill count
24 # EosC | Eosinophill count
25 # BasC | Basophill count
26 # nRBC | Nucleated red blood cell count
27 # RC | Reticulocyte count
28 # MRV | Mean reticulocyte volume
29 # MSCV | Mean sphered cell volume
30 # IRF | Immature reticulocyte fraction
31 # HLSRC | High light scatter reticulocyte count
32 # MicU | Microalbumin in urine
33 # CreaU | Creatinine (enzymatic) in urine
34 # PotU | Potassium in urine
35 # SodU | Sodium in urine
36 # Alb | Albumin
37 # ALP | Alkaline phosphatase
38 # Alanine | Alanine aminotransferase
39 # ApoA | Apolipoprotein A
40 # ApoB | Apolipoprotein B
41 # AA | Aspartate aminotransferase
42 # DBil | Direct bilirubin
43 # Urea | Urea
44 # Calcium | Calcium
45 # Cholesterol | Cholesterol
46 # Creatinine | Creatinine
47 # CRP | C-reactive protein
48 # CystatinC | Cystatin C
49 # GGT | Gamma glutamyltransferase
50 # Glu | Glucose
51 # HbA1c | Glycated haemoglobin (HbA1c)
52 # HDL | HDL cholesterol
53 # IGF1 | IGF-1
54 # LDL | LDL direct
55 # LpA | Lipoprotein A
56 # Oestradiol | Oestradiol
57 # Phosphate | Phosphate
58 # Rheu | Rheumatoid factor
59 # SHBG | SHBG
60 # TotalBil | Total bilirubin
61 # Testosterone | Testosterone
62 # TotalProtein | Total protein
63 # Tri | Triglycerides
64 # Urate | Urate
65 # VitaminD | Vitamin D
66 # smoking | Current tobacco smoking
67 # alcohol | Alcohol intake frequency.
68 # ipaq_activity_group | IPAQ activity group
69 # moderate_activity_met_minutes_week | MET minutes per week for moderate activity
70 # vigorous_activity_met_minutes_week | MET minutes per week for vigorous activity
71 # walking_met_minutes_week | MET minutes per week for walking
72 # total_activity_met_minutes_week | Summed MET minutes per week for all activity
73 # total_activity_days | Summed days activity
74 # total_activity_minutes | Summed minutes activity
75 # heavy_diy_duration | Duration of heavy DIY
76 # light_diy_duration | Duration of light DIY
77 # moderate_activity_duration | Duration of moderate activity
78 # other_exercise_duration | Duration of other exercises
79 # strenuous_sport_duration | Duration of strenuous sports
80 # vigorous_activity_duration | Duration of vigorous activity
81 # walking_duration | Duration of walks
82 # pleasure_walking_duration | Duration walking for pleasure
83 # heavy_diy_frequency_4_weeks | Frequency of heavy DIY in last 4 weeks
84 # light_diy_frequency_4_weeks | Frequency of light DIY in last 4 weeks
85 # other_exercise_frequency_4_weeks | Frequency of other exercises in last 4 weeks
86 # stair_climbing_frequency_4_weeks | Frequency of stair climbing in last 4 weeks
87 # strenuous_sport_frequency_4_weeks | Frequency of strenuous sports in last 4 weeks
88 # pleasure_walking_frequency_4_weeks | Frequency of walking for pleasure in last 4 weeks
89 # moderate_activity_days_week_10min | Number of days/week of moderate physical activity 10+ minutes
90 # vigorous_activity_days_week_10min | Number of days/week of vigorous physical activity 10+ minutes
91 # walking_days_week_10min | Number of days/week walked 10+ minutes
92 # driving_time | Time spent driving
93 # computer_use_time | Time spent using computer
94 # tv_watching_time | Time spent watching television (TV)
95 # physical_activity_types_4_weeks | Types of physical activity in last 4 weeks
96 # nonwork_transport_types | Types of transport used (excluding work)
97 # usual_walking_pace | Usual walking pace
98 # mobile_phone_use_duration | Length of mobile phone use
99 # mobile_phone_use_weekly_3_months | Weekly usage of mobile phone in last 3 months
100 # computer_game_playing | Plays computer games
101 # sleep_duration | Sleep duration
102 # chronotype | Morning/evening person (chronotype)
103 # daytime_napping | Nap during day
104 # insomnia | Sleeplessness / insomnia
105 # daytime_dozing | Daytime dozing / sleeping
106 # ever_smoked | Ever smoked
107 # smoking_pack_years | Pack years of smoking
108 # smoking_status | Smoking status
109 # past_tobacco_smoking | Past tobacco smoking
110 # lifetime_smoking_100_plus | Light smokers, at least 100 smokes in lifetime
111 # current_tobacco_type | Type of tobacco currently smoked
112 # current_cigarettes_per_day | Number of cigarettes currently smoked daily (current cigarette smokers)
113 # previous_cigarettes_per_day_current_cigar_pipe_smokers | Number of cigarettes previously smoked daily (current cigar/pipe smokers)
114 # time_to_first_cigarette | Time from waking to first cigarette
115 # ever_tried_smoking_cessation | Ever tried to stop smoking
116 # smoking_change_vs_10_years_ago | Smoking compared to 10 years previous
117 # previous_tobacco_type | Type of tobacco previously smoked
118 # previous_cigarettes_per_day | Number of cigarettes previously smoked daily
119 # ever_stopped_smoking_6_months | Ever stopped smoking for 6+ months
120 # household_smokers | Smoking/smokers in household
121 # home_secondhand_smoke_exposure | Exposure to tobacco smoke at home
122 # nonhome_secondhand_smoke_exposure | Exposure to tobacco smoke outside home
123 # cooked_vegetable_intake | Cooked vegetable intake
124 # raw_vegetable_intake | Salad / raw vegetable intake
125 # fresh_fruit_intake | Fresh fruit intake
126 # dried_fruit_intake | Dried fruit intake
127 # oily_fish_intake | Oily fish intake
128 # non_oily_fish_intake | Non-oily fish intake
129 # processed_meat_intake | Processed meat intake
130 # poultry_intake | Poultry intake
131 # beef_intake | Beef intake
132 # lamb_mutton_intake | Lamb/mutton intake
133 # pork_intake | Pork intake
134 # age_last_ate_meat | Age when last ate meat
135 # food_avoidance_eggs_dairy_wheat_sugar | Never eat eggs, dairy, wheat, sugar
136 # cheese_intake | Cheese intake
137 # milk_type | Milk type used
138 # spread_type | Spread type
139 # bread_intake | Bread intake
140 # bread_type | Bread type
141 # cereal_intake | Cereal intake
142 # cereal_type | Cereal type
143 # added_salt | Salt added to food
144 # tea_intake | Tea intake
145 # coffee_intake | Coffee intake
146 # coffee_type | Coffee type
147 # hot_drink_temperature | Hot drink temperature
148 # water_intake | Water intake
149 # diet_variation | Variation in diet
150 # alcohol_drinker_status | Alcohol drinker status
151 # former_alcohol_drinker | Former alcohol drinker
152 # red_wine_intake_monthly | Average monthly red wine intake
153 # champagne_white_wine_intake_monthly | Average monthly champagne plus white wine intake
154 # beer_cider_intake_monthly | Average monthly beer plus cider intake
155 # spirits_intake_monthly | Average monthly spirits intake
156 # fortified_wine_intake_monthly | Average monthly fortified wine intake
157 # other_alcohol_intake_monthly | Average monthly intake of other alcoholic drinks
158 # red_wine_intake_weekly | Average weekly red wine intake
159 # champagne_white_wine_intake_weekly | Average weekly champagne plus white wine intake
160 # beer_cider_intake_weekly | Average weekly beer plus cider intake
161 # spirits_intake_weekly | Average weekly spirits intake
162 # fortified_wine_intake_weekly | Average weekly fortified wine intake
163 # other_alcohol_intake_weekly | Average weekly intake of other alcoholic drinks
164 # alcohol_with_meals | Alcohol usually taken with meals
165 # country_of_birth_uk_elsewhere | Country of birth (UK/elsewhere)
166 # breastfed_in_infancy | Breastfed as a baby
167 # comparative_body_size_age_10 | Comparative body size at age 10
168 # comparative_height_age_10 | Comparative height size at age 10
169 # handedness | Handedness (chirality/laterality)
170 # adopted_as_child | Adopted as a child
171 # multiple_birth | Part of a multiple birth
172 # maternal_smoking_around_birth | Maternal smoking around birth
173 # accommodation_type | Type of accommodation lived in
174 # housing_tenure | Own or rent accommodation lived in
175 # gas_solid_fuel_use | Gas or solid-fuel cooking/heating
176 # home_heating_types | Heating type(s) in home
177 # household_vehicle_count | Number of vehicles in household
178 # household_income_before_tax | Average total household income before tax
179 # current_employment_status | Current employment status
180 # current_employment_status_corrected | Current employment status - corrected
181 # home_work_distance | Distance between home and job workplace
182 # main_job_hours_week | Length of working week for main job
183 # commuting_frequency | Frequency of travelling from home to job workplace
184 # commuting_transport_type | Transport type for commuting to job workplace
185 # job_walking_standing | Job involves mainly walking or standing
186 # job_heavy_manual_work | Job involves heavy manual or physical work
187 # job_shift_work | Job involves shift work
188 # job_night_shift_work | Job involves night shift work
189 # educational_qualifications | Qualifications
190 # age_completed_full_time_education | Age completed full time education
191 # friend_family_visit_frequency | Frequency of friend/family visits
192 # leisure_social_activities | Leisure/social activities
193 # ability_to_confide | Able to confide
194 # bipolar_major_depression_status | Bipolar and major depression status
195 # neuroticism_score | Neuroticism score
196 # mood_swings | Mood swings
197 # miserableness | Miserableness
198 # irritability | Irritability
199 # sensitivity_hurt_feelings | Sensitivity / hurt feelings
200 # fed_up_feelings | Fed-up feelings
201 # nervous_feelings | Nervous feelings
202 # worry_anxiety_feelings | Worrier / anxious feelings
203 # tenseness_highly_strung | Tense / 'highly strung'
204 # suffering_from_nerves | Suffer from 'nerves'
205 # loneliness_isolation | Loneliness, isolation
206 # guilty_feelings | Guilty feelings
207 # risk_taking | Risk taking
208 # happiness | Happiness
209 # job_satisfaction | Work/job satisfaction
210 # health_satisfaction | Health satisfaction
211 # family_relationship_satisfaction | Family relationship satisfaction
212 # friendship_satisfaction | Friendships satisfaction
213 # financial_situation_satisfaction | Financial situation satisfaction
214 # depressed_mood_frequency_2_weeks | Frequency of depressed mood in last 2 weeks
215 # disinterest_frequency_2_weeks | Frequency of unenthusiasm / disinterest in last 2 weeks
216 # tenseness_restlessness_frequency_2_weeks | Frequency of tenseness / restlessness in last 2 weeks
217 # tiredness_lethargy_frequency_2_weeks | Frequency of tiredness / lethargy in last 2 weeks
218 # ever_depressed_full_week | Ever depressed for a whole week
219 # longest_depression_duration | Longest period of depression
220 # depression_episode_count | Number of depression episodes
221 # longest_disinterest_duration | Longest period of unenthusiasm / disinterest
222 # disinterest_episode_count | Number of unenthusiastic/disinterested episodes
223 # ever_manic_hyper_2_days | Ever manic/hyper for 2 days
224 # ever_irritable_argumentative_2_days | Ever highly irritable/argumentative for 2 days
225 # manic_hyper_symptoms | Manic/hyper symptoms
226 # longest_manic_irritable_episode_duration | Length of longest manic/irritable episode
227 # manic_irritable_episode_severity | Severity of manic/irritable episodes
228 # adverse_life_events_2_years | Illness, injury, bereavement, stress in last 2 years
229 # outdoor_time_summer | Time spend outdoors in summer
230 # outdoor_time_winter | Time spent outdoors in winter
231 # skin_tanning_ease | Ease of skin tanning
232 # childhood_sunburn_frequency | Childhood sunburn occasions
233 # sun_uv_protection_use | Use of sun/uv protection
234 # solarium_sunlamp_frequency | Frequency of solarium/sunlamp use
235 # proximity_to_major_road | Close to major road
236 # inverse_distance_nearest_major_road | Inverse distance to the nearest major road
237 # inverse_distance_nearest_road | Inverse distance to the nearest road
238 # no2_2005 | Nitrogen dioxide air pollution; 2005
239 # no2_2006 | Nitrogen dioxide air pollution; 2006
240 # no2_2007 | Nitrogen dioxide air pollution; 2007
241 # no2_2010 | Nitrogen dioxide air pollution; 2010
242 # nox_2010 | Nitrogen oxides air pollution; 2010
243 # pm10_2007 | Particulate matter air pollution (pm10); 2007
244 # pm10_2010 | Particulate matter air pollution (pm10); 2010
245 # pm25_absorbance_2010 | Particulate matter air pollution (pm2.5) absorbance; 2010
246 # pm25_2010 | Particulate matter air pollution (pm2.5); 2010
247 # pm25_10_2010 | Particulate matter air pollution 2.5-10um; 2010
248 # major_road_length_100m | Sum of road length of major roads within 100m
249 # major_road_traffic_load | Total traffic load on major roads
250 # nearest_major_road_traffic_intensity | Traffic intensity on the nearest major road
251 # nearest_road_traffic_intensity | Traffic intensity on the nearest road
252 # noise_level_16h | Average 16-hour sound level of noise pollution
253 # noise_level_24h | Average 24-hour sound level of noise pollution
254 # noise_level_daytime | Average daytime sound level of noise pollution
255 # noise_level_evening | Average evening sound level of noise pollution
256 # noise_level_nighttime | Average night-time sound level of noise pollution
257 # natural_environment_percent_1000m | Natural environment percentage, buffer 1000m
258 # natural_environment_percent_300m | Natural environment percentage, buffer 300m
259 # greenspace_percent_1000m | Greenspace percentage, buffer 1000m
260 # greenspace_percent_300m | Greenspace percentage, buffer 300m
261 # domestic_garden_percent_1000m | Domestic garden percentage, buffer 1000m
262 # domestic_garden_percent_300m | Domestic garden percentage, buffer 300m
263 # water_percent_1000m | Water percentage, buffer 1000m
264 # water_percent_300m | Water percentage, buffer 300m
265 # distance_to_coast | Distance (Euclidean) to coast

View File

@@ -0,0 +1,68 @@
# Only assessment/body-measurement variables (field_type=1)
# Generated from field_ids_enriched.csv using prepare_data.py other-info type ordering.
# Format: <extra_info_type_id> # <var_name> | <full_name>
1 # waist_circumference | Waist circumference
2 # hip_circumference | Hip circumference
3 # standing_height | Standing height
4 # fasting_time | Fasting time
5 # pulse_rate | Pulse rate automated reading
6 # dbp | Diastolic blood pressure automated reading
7 # sbp | Systolic blood pressure automated reading
8 # fev1_best | Forced expiratory volume in 1-second (FEV1) Best measure
9 # fvc_best | Forced vital capacity (FVC) Best measure
10 # fev1_fvc_ratio | FEV1/ FVC ratio Z-score
11 # bmi | Body mass index (BMI)
12 # WBC | White blood cell (leukocyte) count
13 # RBC | Red blood cell (erythrocyte) count
14 # hemoglobin | Haemoglobin concentration
15 # hematocrit | Haematocrit percentage
16 # MCV | Mean corpuscular volume
17 # MCH | Mean corpuscular haemoglobin
18 # MCHC | Mean corpuscular haemoglobin concentration
19 # Pc | Platelet count
20 # MPV | Mean platelet (thrombocyte) volume
21 # LymC | Lymphocyte count
22 # MonC | Monocyte count
23 # NeuC | Neutrophill count
24 # EosC | Eosinophill count
25 # BasC | Basophill count
26 # nRBC | Nucleated red blood cell count
27 # RC | Reticulocyte count
28 # MRV | Mean reticulocyte volume
29 # MSCV | Mean sphered cell volume
30 # IRF | Immature reticulocyte fraction
31 # HLSRC | High light scatter reticulocyte count
32 # MicU | Microalbumin in urine
33 # CreaU | Creatinine (enzymatic) in urine
34 # PotU | Potassium in urine
35 # SodU | Sodium in urine
36 # Alb | Albumin
37 # ALP | Alkaline phosphatase
38 # Alanine | Alanine aminotransferase
39 # ApoA | Apolipoprotein A
40 # ApoB | Apolipoprotein B
41 # AA | Aspartate aminotransferase
42 # DBil | Direct bilirubin
43 # Urea | Urea
44 # Calcium | Calcium
45 # Cholesterol | Cholesterol
46 # Creatinine | Creatinine
47 # CRP | C-reactive protein
48 # CystatinC | Cystatin C
49 # GGT | Gamma glutamyltransferase
50 # Glu | Glucose
51 # HbA1c | Glycated haemoglobin (HbA1c)
52 # HDL | HDL cholesterol
53 # IGF1 | IGF-1
54 # LDL | LDL direct
55 # LpA | Lipoprotein A
56 # Oestradiol | Oestradiol
57 # Phosphate | Phosphate
58 # Rheu | Rheumatoid factor
59 # SHBG | SHBG
60 # TotalBil | Total bilirubin
61 # Testosterone | Testosterone
62 # TotalProtein | Total protein
63 # Tri | Triglycerides
64 # Urate | Urate
65 # VitaminD | Vitamin D

View File

@@ -0,0 +1,203 @@
# Only environment/lifestyle exposure variables (field_type=2)
# Generated from field_ids_enriched.csv using prepare_data.py other-info type ordering.
# Format: <extra_info_type_id> # <var_name> | <full_name>
66 # smoking | Current tobacco smoking
67 # alcohol | Alcohol intake frequency.
68 # ipaq_activity_group | IPAQ activity group
69 # moderate_activity_met_minutes_week | MET minutes per week for moderate activity
70 # vigorous_activity_met_minutes_week | MET minutes per week for vigorous activity
71 # walking_met_minutes_week | MET minutes per week for walking
72 # total_activity_met_minutes_week | Summed MET minutes per week for all activity
73 # total_activity_days | Summed days activity
74 # total_activity_minutes | Summed minutes activity
75 # heavy_diy_duration | Duration of heavy DIY
76 # light_diy_duration | Duration of light DIY
77 # moderate_activity_duration | Duration of moderate activity
78 # other_exercise_duration | Duration of other exercises
79 # strenuous_sport_duration | Duration of strenuous sports
80 # vigorous_activity_duration | Duration of vigorous activity
81 # walking_duration | Duration of walks
82 # pleasure_walking_duration | Duration walking for pleasure
83 # heavy_diy_frequency_4_weeks | Frequency of heavy DIY in last 4 weeks
84 # light_diy_frequency_4_weeks | Frequency of light DIY in last 4 weeks
85 # other_exercise_frequency_4_weeks | Frequency of other exercises in last 4 weeks
86 # stair_climbing_frequency_4_weeks | Frequency of stair climbing in last 4 weeks
87 # strenuous_sport_frequency_4_weeks | Frequency of strenuous sports in last 4 weeks
88 # pleasure_walking_frequency_4_weeks | Frequency of walking for pleasure in last 4 weeks
89 # moderate_activity_days_week_10min | Number of days/week of moderate physical activity 10+ minutes
90 # vigorous_activity_days_week_10min | Number of days/week of vigorous physical activity 10+ minutes
91 # walking_days_week_10min | Number of days/week walked 10+ minutes
92 # driving_time | Time spent driving
93 # computer_use_time | Time spent using computer
94 # tv_watching_time | Time spent watching television (TV)
95 # physical_activity_types_4_weeks | Types of physical activity in last 4 weeks
96 # nonwork_transport_types | Types of transport used (excluding work)
97 # usual_walking_pace | Usual walking pace
98 # mobile_phone_use_duration | Length of mobile phone use
99 # mobile_phone_use_weekly_3_months | Weekly usage of mobile phone in last 3 months
100 # computer_game_playing | Plays computer games
101 # sleep_duration | Sleep duration
102 # chronotype | Morning/evening person (chronotype)
103 # daytime_napping | Nap during day
104 # insomnia | Sleeplessness / insomnia
105 # daytime_dozing | Daytime dozing / sleeping
106 # ever_smoked | Ever smoked
107 # smoking_pack_years | Pack years of smoking
108 # smoking_status | Smoking status
109 # past_tobacco_smoking | Past tobacco smoking
110 # lifetime_smoking_100_plus | Light smokers, at least 100 smokes in lifetime
111 # current_tobacco_type | Type of tobacco currently smoked
112 # current_cigarettes_per_day | Number of cigarettes currently smoked daily (current cigarette smokers)
113 # previous_cigarettes_per_day_current_cigar_pipe_smokers | Number of cigarettes previously smoked daily (current cigar/pipe smokers)
114 # time_to_first_cigarette | Time from waking to first cigarette
115 # ever_tried_smoking_cessation | Ever tried to stop smoking
116 # smoking_change_vs_10_years_ago | Smoking compared to 10 years previous
117 # previous_tobacco_type | Type of tobacco previously smoked
118 # previous_cigarettes_per_day | Number of cigarettes previously smoked daily
119 # ever_stopped_smoking_6_months | Ever stopped smoking for 6+ months
120 # household_smokers | Smoking/smokers in household
121 # home_secondhand_smoke_exposure | Exposure to tobacco smoke at home
122 # nonhome_secondhand_smoke_exposure | Exposure to tobacco smoke outside home
123 # cooked_vegetable_intake | Cooked vegetable intake
124 # raw_vegetable_intake | Salad / raw vegetable intake
125 # fresh_fruit_intake | Fresh fruit intake
126 # dried_fruit_intake | Dried fruit intake
127 # oily_fish_intake | Oily fish intake
128 # non_oily_fish_intake | Non-oily fish intake
129 # processed_meat_intake | Processed meat intake
130 # poultry_intake | Poultry intake
131 # beef_intake | Beef intake
132 # lamb_mutton_intake | Lamb/mutton intake
133 # pork_intake | Pork intake
134 # age_last_ate_meat | Age when last ate meat
135 # food_avoidance_eggs_dairy_wheat_sugar | Never eat eggs, dairy, wheat, sugar
136 # cheese_intake | Cheese intake
137 # milk_type | Milk type used
138 # spread_type | Spread type
139 # bread_intake | Bread intake
140 # bread_type | Bread type
141 # cereal_intake | Cereal intake
142 # cereal_type | Cereal type
143 # added_salt | Salt added to food
144 # tea_intake | Tea intake
145 # coffee_intake | Coffee intake
146 # coffee_type | Coffee type
147 # hot_drink_temperature | Hot drink temperature
148 # water_intake | Water intake
149 # diet_variation | Variation in diet
150 # alcohol_drinker_status | Alcohol drinker status
151 # former_alcohol_drinker | Former alcohol drinker
152 # red_wine_intake_monthly | Average monthly red wine intake
153 # champagne_white_wine_intake_monthly | Average monthly champagne plus white wine intake
154 # beer_cider_intake_monthly | Average monthly beer plus cider intake
155 # spirits_intake_monthly | Average monthly spirits intake
156 # fortified_wine_intake_monthly | Average monthly fortified wine intake
157 # other_alcohol_intake_monthly | Average monthly intake of other alcoholic drinks
158 # red_wine_intake_weekly | Average weekly red wine intake
159 # champagne_white_wine_intake_weekly | Average weekly champagne plus white wine intake
160 # beer_cider_intake_weekly | Average weekly beer plus cider intake
161 # spirits_intake_weekly | Average weekly spirits intake
162 # fortified_wine_intake_weekly | Average weekly fortified wine intake
163 # other_alcohol_intake_weekly | Average weekly intake of other alcoholic drinks
164 # alcohol_with_meals | Alcohol usually taken with meals
165 # country_of_birth_uk_elsewhere | Country of birth (UK/elsewhere)
166 # breastfed_in_infancy | Breastfed as a baby
167 # comparative_body_size_age_10 | Comparative body size at age 10
168 # comparative_height_age_10 | Comparative height size at age 10
169 # handedness | Handedness (chirality/laterality)
170 # adopted_as_child | Adopted as a child
171 # multiple_birth | Part of a multiple birth
172 # maternal_smoking_around_birth | Maternal smoking around birth
173 # accommodation_type | Type of accommodation lived in
174 # housing_tenure | Own or rent accommodation lived in
175 # gas_solid_fuel_use | Gas or solid-fuel cooking/heating
176 # home_heating_types | Heating type(s) in home
177 # household_vehicle_count | Number of vehicles in household
178 # household_income_before_tax | Average total household income before tax
179 # current_employment_status | Current employment status
180 # current_employment_status_corrected | Current employment status - corrected
181 # home_work_distance | Distance between home and job workplace
182 # main_job_hours_week | Length of working week for main job
183 # commuting_frequency | Frequency of travelling from home to job workplace
184 # commuting_transport_type | Transport type for commuting to job workplace
185 # job_walking_standing | Job involves mainly walking or standing
186 # job_heavy_manual_work | Job involves heavy manual or physical work
187 # job_shift_work | Job involves shift work
188 # job_night_shift_work | Job involves night shift work
189 # educational_qualifications | Qualifications
190 # age_completed_full_time_education | Age completed full time education
191 # friend_family_visit_frequency | Frequency of friend/family visits
192 # leisure_social_activities | Leisure/social activities
193 # ability_to_confide | Able to confide
194 # bipolar_major_depression_status | Bipolar and major depression status
195 # neuroticism_score | Neuroticism score
196 # mood_swings | Mood swings
197 # miserableness | Miserableness
198 # irritability | Irritability
199 # sensitivity_hurt_feelings | Sensitivity / hurt feelings
200 # fed_up_feelings | Fed-up feelings
201 # nervous_feelings | Nervous feelings
202 # worry_anxiety_feelings | Worrier / anxious feelings
203 # tenseness_highly_strung | Tense / 'highly strung'
204 # suffering_from_nerves | Suffer from 'nerves'
205 # loneliness_isolation | Loneliness, isolation
206 # guilty_feelings | Guilty feelings
207 # risk_taking | Risk taking
208 # happiness | Happiness
209 # job_satisfaction | Work/job satisfaction
210 # health_satisfaction | Health satisfaction
211 # family_relationship_satisfaction | Family relationship satisfaction
212 # friendship_satisfaction | Friendships satisfaction
213 # financial_situation_satisfaction | Financial situation satisfaction
214 # depressed_mood_frequency_2_weeks | Frequency of depressed mood in last 2 weeks
215 # disinterest_frequency_2_weeks | Frequency of unenthusiasm / disinterest in last 2 weeks
216 # tenseness_restlessness_frequency_2_weeks | Frequency of tenseness / restlessness in last 2 weeks
217 # tiredness_lethargy_frequency_2_weeks | Frequency of tiredness / lethargy in last 2 weeks
218 # ever_depressed_full_week | Ever depressed for a whole week
219 # longest_depression_duration | Longest period of depression
220 # depression_episode_count | Number of depression episodes
221 # longest_disinterest_duration | Longest period of unenthusiasm / disinterest
222 # disinterest_episode_count | Number of unenthusiastic/disinterested episodes
223 # ever_manic_hyper_2_days | Ever manic/hyper for 2 days
224 # ever_irritable_argumentative_2_days | Ever highly irritable/argumentative for 2 days
225 # manic_hyper_symptoms | Manic/hyper symptoms
226 # longest_manic_irritable_episode_duration | Length of longest manic/irritable episode
227 # manic_irritable_episode_severity | Severity of manic/irritable episodes
228 # adverse_life_events_2_years | Illness, injury, bereavement, stress in last 2 years
229 # outdoor_time_summer | Time spend outdoors in summer
230 # outdoor_time_winter | Time spent outdoors in winter
231 # skin_tanning_ease | Ease of skin tanning
232 # childhood_sunburn_frequency | Childhood sunburn occasions
233 # sun_uv_protection_use | Use of sun/uv protection
234 # solarium_sunlamp_frequency | Frequency of solarium/sunlamp use
235 # proximity_to_major_road | Close to major road
236 # inverse_distance_nearest_major_road | Inverse distance to the nearest major road
237 # inverse_distance_nearest_road | Inverse distance to the nearest road
238 # no2_2005 | Nitrogen dioxide air pollution; 2005
239 # no2_2006 | Nitrogen dioxide air pollution; 2006
240 # no2_2007 | Nitrogen dioxide air pollution; 2007
241 # no2_2010 | Nitrogen dioxide air pollution; 2010
242 # nox_2010 | Nitrogen oxides air pollution; 2010
243 # pm10_2007 | Particulate matter air pollution (pm10); 2007
244 # pm10_2010 | Particulate matter air pollution (pm10); 2010
245 # pm25_absorbance_2010 | Particulate matter air pollution (pm2.5) absorbance; 2010
246 # pm25_2010 | Particulate matter air pollution (pm2.5); 2010
247 # pm25_10_2010 | Particulate matter air pollution 2.5-10um; 2010
248 # major_road_length_100m | Sum of road length of major roads within 100m
249 # major_road_traffic_load | Total traffic load on major roads
250 # nearest_major_road_traffic_intensity | Traffic intensity on the nearest major road
251 # nearest_road_traffic_intensity | Traffic intensity on the nearest road
252 # noise_level_16h | Average 16-hour sound level of noise pollution
253 # noise_level_24h | Average 24-hour sound level of noise pollution
254 # noise_level_daytime | Average daytime sound level of noise pollution
255 # noise_level_evening | Average evening sound level of noise pollution
256 # noise_level_nighttime | Average night-time sound level of noise pollution
257 # natural_environment_percent_1000m | Natural environment percentage, buffer 1000m
258 # natural_environment_percent_300m | Natural environment percentage, buffer 300m
259 # greenspace_percent_1000m | Greenspace percentage, buffer 1000m
260 # greenspace_percent_300m | Greenspace percentage, buffer 300m
261 # domestic_garden_percent_1000m | Domestic garden percentage, buffer 1000m
262 # domestic_garden_percent_300m | Domestic garden percentage, buffer 300m
263 # water_percent_1000m | Water percentage, buffer 1000m
264 # water_percent_300m | Water percentage, buffer 300m
265 # distance_to_coast | Distance (Euclidean) to coast

View File

@@ -0,0 +1,3 @@
# No extra-info variables.
# Use this file with --extra_info_types_file to train/evaluate with disease history only.
# Keep this file free of numeric type ids; the loader parses it as an empty list.

View File

@@ -0,0 +1,6 @@
# Only smoking, alcohol, and BMI variables
# Generated from field_ids_enriched.csv using prepare_data.py other-info type ordering.
# Format: <extra_info_type_id> # <var_name> | <full_name>
11 # bmi | Body mass index (BMI)
66 # smoking | Current tobacco smoking
67 # alcohol | Alcohol intake frequency.

2564
field_ids_enriched.csv Normal file

File diff suppressed because it is too large Load Diff

115
future_risk.py Normal file
View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from collections.abc import Sequence
import torch
import torch.nn.functional as F
def death_token(vocab_size: int) -> int:
if int(vocab_size) <= 0:
raise ValueError(f"vocab_size must be positive, got {vocab_size}")
return int(vocab_size) - 1
def probabilities_from_logits(
logits: torch.Tensor,
tau_years: float | torch.Tensor,
*,
dist_mode: str = "exponential",
rho: torch.Tensor | None = None,
death_rho: torch.Tensor | None = None,
eps: float = 1e-8,
) -> torch.Tensor:
"""
Convert all-future logits to tau-year event probabilities.
Death is always treated as token vocab_size - 1. For dist_mode="mixed",
non-death tokens use exponential hazards and death uses death_rho.
"""
if logits.ndim != 2:
raise ValueError(f"logits must have shape (N, V), got {tuple(logits.shape)}")
if float(torch.as_tensor(tau_years).detach().min().cpu()) < 0:
raise ValueError("tau_years must be non-negative")
mode = str(dist_mode).lower()
if mode not in {"exponential", "weibull", "mixed"}:
raise ValueError("dist_mode must be one of: exponential, weibull, mixed")
rate = F.softplus(logits) + float(eps)
tau = torch.as_tensor(tau_years, dtype=rate.dtype, device=rate.device)
if tau.ndim == 0:
tau = tau.expand(logits.shape[0])
if tau.ndim != 1 or tau.shape[0] != logits.shape[0]:
raise ValueError(
"tau_years must be a scalar or a 1D tensor with length N, got "
f"{tuple(tau.shape)} for N={logits.shape[0]}"
)
if mode == "exponential":
exposure = tau[:, None].expand_as(rate)
elif mode == "weibull":
if rho is None or rho.shape != logits.shape:
raise ValueError("rho must have the same shape as logits for dist_mode='weibull'")
exposure = torch.pow(tau[:, None].clamp_min(float(eps)), rho.to(rate.dtype))
else:
exposure = tau[:, None].expand_as(rate).clone()
if death_rho is None:
raise ValueError("death_rho is required for dist_mode='mixed'")
death_idx = death_token(logits.shape[1])
death_shape = tuple(death_rho.shape)
death_rho = death_rho.to(device=rate.device, dtype=rate.dtype)
if death_rho.ndim == 2 and death_rho.shape[1] == 1:
death_rho = death_rho.squeeze(1)
if death_rho.ndim != 1 or death_rho.shape[0] != logits.shape[0]:
raise ValueError(
"death_rho must have shape (N,) or (N, 1), got "
f"{death_shape} for N={logits.shape[0]}"
)
exposure[:, death_idx] = torch.pow(tau.clamp_min(float(eps)), death_rho)
return -torch.expm1(-rate * exposure)
def death_risk_from_probabilities(probabilities: torch.Tensor) -> torch.Tensor:
"""Return p_death(t, tau), with death fixed to token vocab_size - 1."""
if probabilities.ndim != 2:
raise ValueError(
f"probabilities must have shape (N, V), got {tuple(probabilities.shape)}"
)
return probabilities[:, death_token(probabilities.shape[1])]
def new_disease_risk_from_probabilities(
probabilities: torch.Tensor,
occurred: torch.Tensor,
disease_ids: Sequence[int],
) -> torch.Tensor:
"""
Compute P(at least one selected disease newly occurs within tau years).
Already occurred diseases are masked out. Death is not included here and
should be reported separately with death_risk_from_probabilities.
"""
if probabilities.ndim != 2 or occurred.shape != probabilities.shape:
raise ValueError(
"probabilities and occurred must both have shape (N, V), got "
f"{tuple(probabilities.shape)} and {tuple(occurred.shape)}"
)
if not disease_ids:
return probabilities.new_zeros(probabilities.shape[0])
death_idx = death_token(probabilities.shape[1])
ids = [
idx
for idx in dict.fromkeys(int(x) for x in disease_ids)
if 0 <= idx < probabilities.shape[1] and idx != death_idx
]
if not ids:
return probabilities.new_zeros(probabilities.shape[0])
idx_tensor = torch.as_tensor(ids, dtype=torch.long, device=probabilities.device)
p = probabilities[:, idx_tensor].clamp(0.0, 1.0 - 1e-7)
new_mask = ~occurred[:, idx_tensor].to(dtype=torch.bool)
log_no_new = torch.log1p(-p) * new_mask.to(dtype=p.dtype)
return -torch.expm1(log_no_new.sum(dim=1))

1256
labels.csv Normal file

File diff suppressed because it is too large Load Diff

400
losses.py Normal file
View File

@@ -0,0 +1,400 @@
from __future__ import annotations
from typing import Iterable
import torch
import torch.nn as nn
import torch.nn.functional as F
PAD_IDX = 0
CHECKUP_IDX = 1
NO_EVENT_IDX = 2
def _make_ignore_mask(
vocab_size: int,
ignored_idx: Iterable[int],
device: torch.device,
) -> torch.Tensor:
ignore_mask = torch.zeros(vocab_size, dtype=torch.bool, device=device)
for idx in ignored_idx:
idx = int(idx)
if 0 <= idx < vocab_size:
ignore_mask[idx] = True
return ignore_mask
def _valid_vocab_mask(
vocab_size: int,
ignored_idx: Iterable[int],
device: torch.device,
) -> torch.Tensor:
return ~_make_ignore_mask(vocab_size, ignored_idx, device)
def _zero_loss_like(logits: torch.Tensor) -> torch.Tensor:
return logits.sum() * 0.0
class Delphi2MLoss(nn.Module):
"""Next-token plus exponential time-to-next-token supervision."""
def __init__(
self,
t_min: float = 1.0 / 365.25,
ignored_tokens: Iterable[int] | None = None,
exclude_ignored_from_intensity: bool = True,
max_exp_input: float = 60.0,
ce_weight: float = 1.0,
time_weight: float = 1.0,
):
super().__init__()
self.t_min = float(t_min)
self.ignored_tokens = (
[PAD_IDX, CHECKUP_IDX]
if ignored_tokens is None
else [int(x) for x in ignored_tokens]
)
self.exclude_ignored_from_intensity = bool(exclude_ignored_from_intensity)
self.max_exp_input = float(max_exp_input)
self.ce_weight = float(ce_weight)
self.time_weight = float(time_weight)
def forward(
self,
logits: torch.Tensor,
target_events: torch.Tensor,
target_times: torch.Tensor,
current_times: torch.Tensor,
padding_mask: torch.Tensor,
return_components: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, dict[str, torch.Tensor]]:
if logits.dim() != 3:
raise ValueError(f"logits must be (B, L, K), got {tuple(logits.shape)}")
bsz, seq_len, vocab_size = logits.shape
expected = (bsz, seq_len)
if target_events.shape != expected:
raise ValueError(f"target_events must be {expected}, got {tuple(target_events.shape)}")
if target_times.shape != expected:
raise ValueError(f"target_times must be {expected}, got {tuple(target_times.shape)}")
if current_times.shape != expected:
raise ValueError(f"current_times must be {expected}, got {tuple(current_times.shape)}")
if padding_mask.shape != expected:
raise ValueError(f"padding_mask must be {expected}, got {tuple(padding_mask.shape)}")
valid_mask = padding_mask.bool()
for idx in self.ignored_tokens:
valid_mask = valid_mask & (target_events != int(idx))
valid_mask = valid_mask & (target_events > PAD_IDX)
if not valid_mask.any():
total_loss = _zero_loss_like(logits)
if return_components:
return total_loss, {
"ce": total_loss.detach(),
"time": total_loss.detach(),
"total": total_loss.detach(),
}
return total_loss
logits_valid = logits[valid_mask]
target_events_valid = target_events[valid_mask]
target_times_valid = target_times[valid_mask]
current_times_valid = current_times[valid_mask]
logits_safe = torch.nan_to_num(
logits_valid,
nan=0.0,
posinf=self.max_exp_input,
neginf=-self.max_exp_input,
)
loss_ce = F.cross_entropy(
logits_safe,
target_events_valid,
reduction="mean",
)
logits_for_lse = logits_safe
if self.exclude_ignored_from_intensity:
ignore_mask = _make_ignore_mask(vocab_size, self.ignored_tokens, logits.device)
logits_for_lse = logits_safe.masked_fill(ignore_mask.unsqueeze(0), float("-inf"))
log_lambda_total = torch.logsumexp(logits_for_lse, dim=-1)
log_lambda_total = -torch.log(torch.exp(-log_lambda_total) + self.t_min)
dt = torch.clamp(target_times_valid - current_times_valid, min=self.t_min)
log_dt_inv = -torch.log(dt + self.t_min)
loss_dt = -(
log_lambda_total
- torch.exp(
torch.clamp(log_lambda_total - log_dt_inv, max=self.max_exp_input)
)
)
loss_dt = loss_dt.mean()
total_loss = self.ce_weight * loss_ce + self.time_weight * loss_dt
if return_components:
return total_loss, {
"ce": loss_ce.detach(),
"time": loss_dt.detach(),
"total": total_loss.detach(),
}
return total_loss
class UniqueTimeSetExponentialLoss(nn.Module):
"""Next distinct timestamp event-set supervision with sum reduction."""
def __init__(
self,
ignored_idx: Iterable[int] = (PAD_IDX, CHECKUP_IDX),
t_min: float = 1.0 / 365.25,
max_exp_input: float = 60.0,
exclude_ignored_from_intensity: bool = True,
):
super().__init__()
self.ignored_idx = [int(x) for x in ignored_idx]
self.t_min = float(t_min)
self.max_exp_input = float(max_exp_input)
self.exclude_ignored_from_intensity = bool(exclude_ignored_from_intensity)
def forward(
self,
logits: torch.Tensor,
target_multi_hot: torch.Tensor,
target_dt_unique: torch.Tensor,
readout_mask: torch.Tensor,
return_components: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, dict[str, torch.Tensor]]:
if logits.dim() != 3:
raise ValueError(f"logits must be (B, L, K), got {tuple(logits.shape)}")
bsz, seq_len, vocab_size = logits.shape
if target_multi_hot.shape != (bsz, seq_len, vocab_size):
raise ValueError(
"target_multi_hot must match logits shape, "
f"got {tuple(target_multi_hot.shape)} vs {tuple(logits.shape)}"
)
if target_dt_unique.shape != (bsz, seq_len):
raise ValueError(
f"target_dt_unique must be {(bsz, seq_len)}, got {tuple(target_dt_unique.shape)}"
)
if readout_mask.shape != (bsz, seq_len):
raise ValueError(f"readout_mask must be {(bsz, seq_len)}, got {tuple(readout_mask.shape)}")
ignore_mask = _make_ignore_mask(vocab_size, self.ignored_idx, logits.device)
num_targets = target_multi_hot[:, :, ~ignore_mask].sum(dim=-1)
valid_mask = readout_mask.bool() & (num_targets > 0)
if not valid_mask.any():
total_loss = _zero_loss_like(logits)
if return_components:
return total_loss, {
"observed": total_loss.detach(),
"penalty": total_loss.detach(),
"total": total_loss.detach(),
}
return total_loss
logits_safe = torch.nan_to_num(
logits[valid_mask],
nan=0.0,
posinf=self.max_exp_input,
neginf=-self.max_exp_input,
)
target_valid = target_multi_hot[valid_mask].to(logits_safe.dtype)
target_valid[:, ignore_mask] = 0.0
observed_term = (logits_safe * target_valid).sum(dim=-1)
penalty_scale = target_valid.sum(dim=-1)
logits_for_lse = logits_safe
if self.exclude_ignored_from_intensity:
logits_for_lse = logits_safe.masked_fill(ignore_mask.unsqueeze(0), float("-inf"))
dt_clamped = torch.clamp(target_dt_unique[valid_mask], min=self.t_min)
log_lambda_total = torch.logsumexp(logits_for_lse, dim=-1)
log_penalty = log_lambda_total + dt_clamped.log()
penalty = torch.exp(torch.clamp(log_penalty, max=self.max_exp_input))
observed_loss = -observed_term
penalty_loss = penalty_scale * penalty
total_loss = (observed_loss + penalty_loss).mean()
if return_components:
return total_loss, {
"observed": observed_loss.mean().detach(),
"penalty": penalty_loss.mean().detach(),
"total": total_loss.detach(),
}
return total_loss
class ExponentialLoss(nn.Module):
"""Query-conditioned all-future-event exponential point-process loss."""
def __init__(
self,
ignored_idx: Iterable[int] = (PAD_IDX, CHECKUP_IDX),
eps: float = 1e-8,
):
super().__init__()
self.ignored_idx = tuple(int(i) for i in ignored_idx)
self.eps = eps
def forward(
self,
logits: torch.Tensor,
targets: torch.Tensor,
exposure: torch.Tensor,
) -> torch.Tensor:
_, vocab_size = logits.shape
rate = F.softplus(logits) + self.eps
valid_vocab = _valid_vocab_mask(vocab_size, self.ignored_idx, logits.device)
penalty = exposure.to(rate.dtype) * rate[:, valid_vocab].sum(dim=-1)
target_valid = torch.ones_like(targets, dtype=torch.bool, device=logits.device)
for idx in self.ignored_idx:
target_valid &= targets != idx
safe_targets = targets.clamp(min=0, max=vocab_size - 1)
observed = rate.log().gather(1, safe_targets)
observed = (observed * target_valid.to(rate.dtype)).sum(dim=-1)
return (-observed + penalty).mean()
class WeibullLoss(nn.Module):
"""Query-conditioned all-future-event Weibull point-process loss."""
def __init__(
self,
ignored_idx: Iterable[int] = (PAD_IDX, CHECKUP_IDX),
eps: float = 1e-8,
):
super().__init__()
self.ignored_idx = tuple(int(i) for i in ignored_idx)
self.eps = eps
def forward(
self,
logits: torch.Tensor,
weibull_rho: torch.Tensor,
targets: torch.Tensor,
dt: torch.Tensor,
exposure: torch.Tensor,
) -> torch.Tensor:
_, vocab_size = logits.shape
if weibull_rho is None:
raise ValueError("weibull_rho is required for WeibullLoss")
if weibull_rho.shape != logits.shape:
raise ValueError(
"weibull_rho must have the same shape as logits. "
f"Got logits={tuple(logits.shape)}, weibull_rho={tuple(weibull_rho.shape)}"
)
dtype = logits.dtype
rate = F.softplus(logits) + self.eps
rho = weibull_rho.to(device=logits.device, dtype=dtype).clamp_min(self.eps)
valid_vocab = _valid_vocab_mask(vocab_size, self.ignored_idx, logits.device)
t_exp = exposure.to(dtype).clamp_min(self.eps).unsqueeze(1)
penalty = (rate * torch.pow(t_exp, rho))[:, valid_vocab].sum(dim=-1)
target_valid = torch.ones_like(targets, dtype=torch.bool, device=logits.device)
for idx in self.ignored_idx:
target_valid &= targets != idx
safe_targets = targets.clamp(min=0, max=vocab_size - 1)
target_rate = rate.gather(1, safe_targets)
target_rho = rho.gather(1, safe_targets)
target_dt = dt.to(dtype).clamp_min(self.eps)
log_intensity = (
target_rate.log()
+ target_rho.log()
+ (target_rho - 1.0) * target_dt.log()
)
observed = (log_intensity * target_valid.to(dtype)).sum(dim=-1)
return (-observed + penalty).mean()
class MixedLoss(nn.Module):
"""Exponential diseases plus one Weibull death endpoint."""
def __init__(
self,
death_idx: int,
ignored_idx: Iterable[int] = (PAD_IDX, CHECKUP_IDX),
eps: float = 1e-8,
):
super().__init__()
self.death_idx = int(death_idx)
self.ignored_idx = tuple(int(i) for i in ignored_idx)
self.eps = eps
def forward(
self,
logits: torch.Tensor,
death_rho: torch.Tensor,
targets: torch.Tensor,
dt: torch.Tensor,
exposure: torch.Tensor,
) -> torch.Tensor:
_, vocab_size = logits.shape
dtype = logits.dtype
rate = F.softplus(logits) + self.eps
if death_rho.dim() == 2:
death_rho = death_rho.squeeze(-1)
death_rho = death_rho.to(device=logits.device, dtype=dtype).clamp_min(self.eps)
valid_vocab = _valid_vocab_mask(vocab_size, self.ignored_idx, logits.device)
valid_disease_vocab = valid_vocab.clone()
valid_disease_vocab[self.death_idx] = False
t_exp = exposure.to(dtype).clamp_min(self.eps)
disease_penalty = t_exp * rate[:, valid_disease_vocab].sum(dim=-1)
death_rate = rate[:, self.death_idx]
death_penalty = death_rate * torch.pow(t_exp, death_rho)
penalty = disease_penalty + death_penalty
target_valid = torch.ones_like(targets, dtype=torch.bool, device=logits.device)
for idx in self.ignored_idx:
target_valid &= targets != idx
disease_event_mask = target_valid & (targets != self.death_idx)
safe_targets = targets.clamp(min=0, max=vocab_size - 1)
disease_log_rate = rate.log().gather(1, safe_targets)
observed_disease = (disease_log_rate * disease_event_mask.to(dtype)).sum(dim=-1)
death_event_mask = target_valid & (targets == self.death_idx)
death_observed = death_event_mask.any(dim=1)
death_dt = (dt.to(dtype).clamp_min(self.eps) * death_event_mask.to(dtype)).sum(dim=1)
death_log_intensity = (
death_rate.log()
+ death_rho.log()
+ (death_rho - 1.0) * death_dt.clamp_min(self.eps).log()
)
observed_death = death_log_intensity * death_observed.to(dtype)
return (-observed_disease - observed_death + penalty).mean()
def build_loss(name: str, **kwargs) -> nn.Module:
name = name.lower()
if name in {"delphi2m", "d2m", "next_token"}:
return Delphi2MLoss(**kwargs)
if name in {"uts", "unique_time_set", "unique_time_exponential"}:
return UniqueTimeSetExponentialLoss(**kwargs)
if name in {"exponential", "query_exponential"}:
return ExponentialLoss(**kwargs)
if name in {"weibull", "query_weibull"}:
return WeibullLoss(**kwargs)
if name in {"mixed", "query_mixed"}:
return MixedLoss(**kwargs)
raise ValueError(
f"Unknown loss {name!r}. Available: delphi2m, uts, exponential, weibull, mixed."
)

481
models.py Normal file
View File

@@ -0,0 +1,481 @@
from dataclasses import dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
from backbones import (
AgeSinusoidalEncoding,
GPTBlock,
GaussianRBFTimeBasis,
TimeRoPE,
TokenAutoDiscretization,
)
from targets import PAD_IDX
@dataclass
class DeepHealthOutput:
hidden: torch.Tensor
time_seq: torch.Tensor
padding_mask: torch.Tensor
event_len: int
class OtherInfoTokenizer(nn.Module):
PAD_KIND = 0
CONT_KIND = 1
CATE_KIND = 2
def __init__(
self,
n_embd: int,
n_types: int,
n_cont_types: int,
n_categories: int,
cont_type_ids: list[int],
n_value_kinds: int = 3,
n_bins: int = 16,
):
super().__init__()
if len(cont_type_ids) != n_cont_types:
raise ValueError(
"cont_type_ids length must match n_cont_types, got "
f"{len(cont_type_ids)} vs {n_cont_types}"
)
if n_types <= 0:
raise ValueError(f"n_types must include PAD and be > 0, got {n_types}")
if n_categories <= 0:
raise ValueError(
f"n_categories must include PAD and be > 0, got {n_categories}"
)
if n_value_kinds <= self.CATE_KIND:
raise ValueError(
f"n_value_kinds must be > {self.CATE_KIND}, got {n_value_kinds}"
)
self.type_emb = nn.Embedding(n_types, n_embd, padding_idx=0)
self.kind_emb = nn.Embedding(n_value_kinds, n_embd, padding_idx=0)
self.cont_value_encoder = (
TokenAutoDiscretization(
n_cont_types=n_cont_types,
n_bins=n_bins,
n_embd=n_embd,
)
if n_cont_types > 0
else None
)
self.cate_value_emb = nn.Embedding(
n_categories,
n_embd,
padding_idx=0,
)
cont_type_index = torch.full((n_types,), -1, dtype=torch.long)
for idx, type_id in enumerate(cont_type_ids):
if type_id <= 0 or type_id >= n_types:
raise ValueError(
f"continuous type id {type_id} must be in [1, {n_types})"
)
cont_type_index[type_id] = idx
self.register_buffer(
"cont_type_index",
cont_type_index,
persistent=False,
)
self.reset_parameters()
def reset_parameters(self) -> None:
nn.init.normal_(self.type_emb.weight, mean=0.0, std=0.02)
nn.init.zeros_(self.type_emb.weight[0])
nn.init.normal_(self.kind_emb.weight, mean=0.0, std=0.02)
nn.init.zeros_(self.kind_emb.weight[0])
nn.init.normal_(self.cate_value_emb.weight, mean=0.0, std=0.02)
nn.init.zeros_(self.cate_value_emb.weight[0])
def forward(
self,
other_type: torch.LongTensor,
other_value: torch.Tensor,
other_value_kind: torch.LongTensor,
) -> tuple[torch.Tensor, torch.Tensor]:
if other_type.shape != other_value.shape:
raise ValueError(
"other_type and other_value must have the same shape, got "
f"{tuple(other_type.shape)} vs {tuple(other_value.shape)}"
)
if other_type.shape != other_value_kind.shape:
raise ValueError(
"other_type and other_value_kind must have the same shape, got "
f"{tuple(other_type.shape)} vs {tuple(other_value_kind.shape)}"
)
other_valid = other_type > 0
type_emb = self.type_emb(other_type)
kind_emb = self.kind_emb(other_value_kind)
value_emb = torch.zeros_like(type_emb)
cont_pos = other_valid & (other_value_kind == self.CONT_KIND)
if cont_pos.any():
if self.cont_value_encoder is None:
raise ValueError("continuous tokens found but n_cont_types is 0")
cont_idx = self.cont_type_index[other_type[cont_pos]]
if (cont_idx < 0).any():
bad_type = other_type[cont_pos][cont_idx < 0][0].item()
raise ValueError(
f"type_id={bad_type} is marked continuous but is not in "
"cont_type_ids"
)
value_emb[cont_pos] = self.cont_value_encoder(
cont_type_idx=cont_idx,
value=other_value[cont_pos].to(type_emb.dtype),
)
cate_pos = other_valid & (other_value_kind == self.CATE_KIND)
if cate_pos.any():
cate_id = other_value[cate_pos].long()
value_emb[cate_pos] = self.cate_value_emb(cate_id)
out = type_emb + kind_emb + value_emb
out = out * other_valid.unsqueeze(-1).to(out.dtype)
return out, other_valid
class DeepHealth(nn.Module):
def __init__(
self,
vocab_size: int,
n_embd: int,
n_head: int,
n_hist_layer: int,
n_tab_layer: int,
n_types: int,
n_cont_types: int,
n_categories: int,
cont_type_ids: list[int],
n_value_kinds: int = 3,
n_bins: int = 16,
target_mode: str = "next_token", # "next_token" or "all_future"
time_mode: str = "relative", # "relative" or "absolute"
dist_mode: str = "exponential", # "exponential", "weibull" or "mixed"
extra_pool_reduce: str = "mean",
dropout: float = 0.0,
):
super().__init__()
if target_mode not in ["next_token", "all_future"]:
raise ValueError(
"target_mode must be either 'next_token' or 'all_future'")
if time_mode not in ["relative", "absolute"]:
raise ValueError(
"time_mode must be either 'relative' or 'absolute'")
if dist_mode not in ["exponential", "weibull", "mixed"]:
raise ValueError(
"dist_mode must be either 'exponential', 'weibull' or 'mixed'")
if extra_pool_reduce not in {"mean", "sum"}:
raise ValueError("extra_pool_reduce must be either 'mean' or 'sum'")
self.token_embedding = nn.Embedding(vocab_size, n_embd, padding_idx=0)
self.gender_embedding = nn.Embedding(
2, n_embd) # Assuming binary gender
self.tokenizer = OtherInfoTokenizer(
n_embd=n_embd,
n_types=n_types,
n_cont_types=n_cont_types,
n_categories=n_categories,
cont_type_ids=cont_type_ids,
n_value_kinds=n_value_kinds,
n_bins=n_bins,
)
self.target_mode = target_mode
self.time_mode = time_mode
self.dist_mode = dist_mode
self.extra_pool_reduce = extra_pool_reduce
self.n_embd = n_embd
self.vocab_size = vocab_size
nn.init.normal_(self.token_embedding.weight, mean=0.0, std=0.02)
nn.init.zeros_(self.token_embedding.weight[0])
nn.init.normal_(self.gender_embedding.weight, mean=0.0, std=0.02)
if dist_mode == "weibull":
self.rho_head = nn.Linear(n_embd, vocab_size)
nn.init.zeros_(self.rho_head.weight)
nn.init.constant_(self.rho_head.bias, 0.5413)
if dist_mode == "mixed":
self.death_idx = vocab_size - 1
self.rho_death_head = nn.Linear(n_embd, 1)
nn.init.zeros_(self.rho_death_head.weight)
nn.init.constant_(self.rho_death_head.bias, 0.5413)
if time_mode == "absolute":
self.age_encoding = AgeSinusoidalEncoding(n_embd)
self.blocks = nn.ModuleList([
GPTBlock(
n_embd=n_embd,
n_head=n_head,
use_time_rope=False,
use_rbf_bias=False,
mlp_dropout=dropout,
) for _ in range(n_hist_layer)
])
self.rope = None
self.rbf = None
elif time_mode == "relative":
self.age_encoding = None
self.blocks = nn.ModuleList([
GPTBlock(
n_embd=n_embd,
n_head=n_head,
use_time_rope=True,
use_rbf_bias=True,
mlp_dropout=dropout,
) for _ in range(n_hist_layer)
])
self.rope = TimeRoPE(n_embd // n_head)
self.rbf = GaussianRBFTimeBasis(n_bases=16, max_time_diff=40.0)
self.final_ln = nn.LayerNorm(n_embd)
self.risk_head = nn.Linear(n_embd, vocab_size, bias=False)
if target_mode == "next_token":
self.risk_head.weight = self.token_embedding.weight
self.query_token = nn.Parameter(torch.zeros(n_embd))
nn.init.normal_(self.query_token, mean=0.0, std=0.02)
def _make_history_attn_mask(
self,
padding_mask: torch.Tensor,
time_seq: torch.Tensor,
dtype: torch.dtype,
) -> torch.Tensor:
valid_key = padding_mask[:, None, :] # (B, 1, L)
visible_by_time = time_seq[:, None, :] <= time_seq[:, :, None]
valid = valid_key & visible_by_time
return torch.zeros(
valid.shape,
device=valid.device,
dtype=dtype,
).masked_fill(~valid, -1e4)[:, None, :, :]
def _pool_other_by_time(
self,
h_other: torch.Tensor,
other_time: torch.Tensor,
other_mask: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
batch_size, n_other, n_embd = h_other.shape
if n_other == 0:
empty_h = h_other.new_zeros(batch_size, 0, n_embd)
empty_t = other_time.new_zeros(batch_size, 0)
empty_m = torch.zeros(batch_size, 0, dtype=torch.bool, device=h_other.device)
return empty_h, empty_t, empty_m
masked_time = other_time.masked_fill(~other_mask, float("inf"))
_sorted_time_with_pad, order = masked_time.sort(dim=1)
sorted_time = other_time.gather(1, order)
sorted_mask = other_mask.gather(1, order)
sorted_h = h_other.gather(1, order.unsqueeze(-1).expand(-1, -1, n_embd))
group_start = torch.zeros_like(sorted_mask)
group_start[:, 0] = sorted_mask[:, 0]
group_start[:, 1:] = sorted_mask[:, 1:] & (
sorted_time[:, 1:] != sorted_time[:, :-1]
)
group_id = group_start.long().cumsum(dim=1) - 1
max_groups = int(group_start.sum(dim=1).max().item())
pooled_h = h_other.new_zeros(batch_size, max_groups, n_embd)
pooled_time = other_time.new_zeros(batch_size, max_groups)
pooled_mask = torch.zeros(
batch_size,
max_groups,
dtype=torch.bool,
device=h_other.device,
)
if max_groups == 0:
return pooled_h, pooled_time, pooled_mask
safe_group_id = group_id.clamp_min(0)
pooled_h.scatter_add_(
1,
safe_group_id.unsqueeze(-1).expand_as(sorted_h),
sorted_h * sorted_mask.unsqueeze(-1).to(sorted_h.dtype),
)
if self.extra_pool_reduce == "mean":
counts = h_other.new_zeros(batch_size, max_groups, 1)
counts.scatter_add_(
1,
safe_group_id.unsqueeze(-1),
sorted_mask.unsqueeze(-1).to(h_other.dtype),
)
pooled_h = pooled_h / counts.clamp_min(1.0)
pooled_time.scatter_add_(
1,
safe_group_id,
sorted_time * group_start.to(sorted_time.dtype),
)
group_count = group_start.sum(dim=1)
arange_groups = torch.arange(max_groups, device=h_other.device)
pooled_mask = arange_groups.unsqueeze(0) < group_count.unsqueeze(1)
return pooled_h, pooled_time, pooled_mask
def _forward_shared(
self,
event_seq: torch.LongTensor,
time_seq: torch.FloatTensor,
sex: torch.LongTensor,
mode: str,
padding_mask: torch.Tensor | None = None,
t_query: torch.FloatTensor | None = None,
other_type: torch.LongTensor | None = None,
other_value: torch.Tensor | None = None,
other_value_kind: torch.LongTensor | None = None,
other_time: torch.FloatTensor | None = None,
return_output: bool = False,
**unused_kwargs,
) -> torch.Tensor | DeepHealthOutput:
if unused_kwargs:
unknown = ", ".join(sorted(unused_kwargs))
raise TypeError(f"Unexpected DeepHealth forward arguments: {unknown}")
if mode not in {"next_token", "all_future"}:
raise ValueError("mode must be either 'next_token' or 'all_future'")
if mode == "all_future" and t_query is None:
raise ValueError("t_query is required when mode='all_future'")
if (
other_type is None
or other_value is None
or other_value_kind is None
or other_time is None
):
raise ValueError(
"DeepHealth expects other_type, other_value, "
"other_value_kind, and other_time."
)
if padding_mask is None:
padding_mask = event_seq > PAD_IDX
else:
padding_mask = padding_mask.to(device=event_seq.device, dtype=torch.bool)
event_len = event_seq.size(1)
h_disease = self.token_embedding(event_seq)
t_disease = time_seq
if other_time.shape != other_type.shape:
raise ValueError(
"other_time must have the same shape as other_type, got "
f"{tuple(other_time.shape)} vs {tuple(other_type.shape)}"
)
other_time = other_time.to(device=event_seq.device, dtype=time_seq.dtype)
h_other, other_mask = self.tokenizer(
other_type=other_type,
other_value=other_value,
other_value_kind=other_value_kind,
)
h_other = h_other.to(device=event_seq.device)
other_mask = other_mask.to(device=event_seq.device, dtype=torch.bool)
h_disease = torch.cat([h_disease, h_other], dim=1)
t_disease = torch.cat([t_disease, other_time], dim=1)
padding_mask = torch.cat([padding_mask, other_mask], dim=1)
h_disease = h_disease * padding_mask.unsqueeze(-1).to(h_disease.dtype)
if mode == "all_future":
batch_size = event_seq.size(0)
query = self.query_token.view(1, 1, -1).expand(batch_size, 1, -1)
h_disease = torch.cat([h_disease, query], dim=1)
t_disease = torch.cat([t_disease, t_query[:, None]], dim=1)
query_mask = torch.ones(
batch_size,
1,
dtype=torch.bool,
device=event_seq.device,
)
padding_mask = torch.cat([padding_mask, query_mask], dim=1)
sex_emb = self.gender_embedding(sex)[:, None, :]
h_disease = h_disease + sex_emb
h_disease = h_disease * padding_mask.unsqueeze(-1).to(h_disease.dtype)
rope_cache = None
rbf_cache = None
if self.time_mode == "absolute":
h_disease = h_disease + self.age_encoding(t_disease)
h_disease = h_disease * padding_mask.unsqueeze(-1).to(h_disease.dtype)
elif self.time_mode == "relative":
rope_cache = self.rope.precompute_cache(t_disease)
rbf_cache = self.rbf.precompute_cache(t_disease)
attn_mask = self._make_history_attn_mask(
padding_mask=padding_mask,
time_seq=t_disease,
dtype=h_disease.dtype,
)
for block in self.blocks:
h_disease = block(
h_disease,
rope_cache=rope_cache,
rbf_cache=rbf_cache,
attn_mask=attn_mask,
)
h_disease = h_disease * padding_mask.unsqueeze(-1).to(h_disease.dtype)
h_disease = self.final_ln(h_disease)
h_disease = h_disease * padding_mask.unsqueeze(-1).to(h_disease.dtype)
if mode == "all_future":
hidden = h_disease[:, -1, :]
if return_output:
return DeepHealthOutput(
hidden=hidden,
time_seq=t_query[:, None],
padding_mask=torch.ones(
hidden.size(0),
1,
dtype=torch.bool,
device=hidden.device,
),
event_len=event_len,
)
return hidden
if return_output:
h_event = h_disease[:, :event_len, :]
t_event = t_disease[:, :event_len]
event_mask = padding_mask[:, :event_len]
h_extra, t_extra, extra_mask = self._pool_other_by_time(
h_other=h_disease[:, event_len:, :],
other_time=t_disease[:, event_len:],
other_mask=padding_mask[:, event_len:],
)
return DeepHealthOutput(
hidden=torch.cat([h_event, h_extra], dim=1),
time_seq=torch.cat([t_event, t_extra], dim=1),
padding_mask=torch.cat([event_mask, extra_mask], dim=1),
event_len=event_len,
)
return h_disease[:, :event_len, :]
def forward_next_token(self, **kwargs) -> torch.Tensor:
return self._forward_shared(mode="next_token", **kwargs)
def forward_all_future(self, **kwargs) -> torch.Tensor:
return self._forward_shared(mode="all_future", **kwargs)
def forward(self, target_mode: str | None = None, **kwargs) -> torch.Tensor:
mode = self.target_mode if target_mode is None else target_mode
return self._forward_shared(mode=mode, **kwargs)
def calc_risk(self, x: torch.Tensor) -> torch.Tensor:
return self.risk_head(x)
def calc_weibull_rho(self, x: torch.Tensor) -> torch.Tensor:
if self.dist_mode != "weibull":
raise RuntimeError(
f"calc_weibull_rho called with dist_mode={self.dist_mode!r}"
)
return F.softplus(self.rho_head(x)) + 1e-6
def calc_death_rho(self, x: torch.Tensor) -> torch.Tensor:
if self.dist_mode != "mixed":
raise RuntimeError(
f"calc_death_rho called with dist_mode={self.dist_mode!r}"
)
return F.softplus(self.rho_death_head(x)).squeeze(-1) + 1e-6

385
prepare_data.py Normal file
View File

@@ -0,0 +1,385 @@
"""ETL pipeline for UK Biobank data preparation.
This script converts raw UK Biobank CSV exports into the artefacts consumed by
DeepHealth:
* ``ukb_event_data.npy``: ``(N, 3)`` uint32 array of ``(eid, days, label)``
disease/death/checkup events sorted by patient then time.
* ``ukb_basic_info.csv``: basic patient table indexed by ``eid`` with ``sex``.
* ``ukb_other_info.npy``: ``(M, 5)`` float64 array of
``(eid, type, value, value_kind, time)`` rows. ``type=0`` is reserved for
padding, ``value_kind=1`` means continuous, and ``value_kind=2`` means
categorical.
* ``cate_types.csv``: categorical-variable metadata with
``type,name,n_categories``. Dataset code computes global category ids after
experiment-specific variable selection.
Processing steps
----------------
1. Stream the raw CSV in 10 000-row chunks to bound memory usage.
2. Convert date columns to integer day offsets from date of birth.
3. Extract ICD-10 date fields and cancer date/type fields into long-form
events and map codes to integer labels via ``labels.csv``.
4. De-duplicate events per ``(eid, label)`` keeping the earliest occurrence.
5. Convert available non-sex tabular fields into unified other-information
tokens timestamped by ``date_of_assessment``.
6. Write event data, sex, unified other-information tokens, and categorical
type metadata.
Usage
-----
::
python prepare_data.py
The script expects these files in the working directory:
* ``ukb_data.csv``: raw UK Biobank CSV export.
* ``field_ids_enriched.csv``: field-ID to column-name mapping.
* ``icd10_codes_mod.tsv``: ICD-10 field to date-column mapping.
* ``labels.csv``: disease-code to integer-label mapping.
"""
import numpy as np # Numerical operations
import pandas as pd # Pandas for data manipulation
import tqdm # Progress bar for chunk processing
CONT_VALUE_KIND = 1
CATE_VALUE_KIND = 2
def _sort_values(values):
"""Sort mixed pandas/numpy scalar values deterministically."""
try:
return sorted(values)
except TypeError:
return sorted(values, key=lambda x: str(x))
def _build_other_info_tokens(
table: pd.DataFrame,
feature_fields: list[str],
*,
time_col: str = "date_of_assessment",
) -> tuple[np.ndarray, pd.DataFrame]:
"""Convert wide tabular features into (eid, type, value, kind, time) rows."""
rows = []
cate_meta = []
present_features = [
col for col in feature_fields
if col in table.columns and col not in {time_col, "sex"}
]
if time_col not in table.columns:
raise ValueError(
f"{time_col!r} is required to timestamp other-information tokens"
)
token_times = pd.to_numeric(table[time_col], errors="coerce")
for type_idx, col in enumerate(present_features, start=1):
series = table[col]
n_unique = series.dropna().nunique()
valid_time = token_times.notna()
if n_unique > 10:
numeric = pd.to_numeric(series, errors="coerce")
valid = numeric.notna() & valid_time
if not valid.any():
continue
rows.append(
np.column_stack(
(
table.index[valid].to_numpy(dtype=np.float64),
np.full(valid.sum(), type_idx, dtype=np.float64),
numeric[valid].to_numpy(dtype=np.float64),
np.full(valid.sum(), CONT_VALUE_KIND, dtype=np.float64),
token_times[valid].to_numpy(dtype=np.float64),
)
)
)
else:
unique_vals = _sort_values(series.dropna().unique())
value_map = {val: idx + 1 for idx, val in enumerate(unique_vals)}
mapped = series.map(value_map)
valid = mapped.notna() & valid_time
n_categories = len(unique_vals)
cate_meta.append(
{
"type": type_idx,
"name": col,
"n_categories": n_categories,
}
)
if not valid.any():
continue
rows.append(
np.column_stack(
(
table.index[valid].to_numpy(dtype=np.float64),
np.full(valid.sum(), type_idx, dtype=np.float64),
mapped[valid].to_numpy(dtype=np.float64),
np.full(valid.sum(), CATE_VALUE_KIND, dtype=np.float64),
token_times[valid].to_numpy(dtype=np.float64),
)
)
)
cate_types = pd.DataFrame(
cate_meta,
columns=["type", "name", "n_categories"],
)
if not rows:
return np.empty((0, 5), dtype=np.float64), cate_types
out = np.vstack(rows)
return out[np.lexsort((out[:, 3], out[:, 1], out[:, 0]))], cate_types
def _unique_preserve_order(values):
"""Return unique values while preserving first-seen order."""
seen = set()
out = []
for value in values:
if value not in seen:
seen.add(value)
out.append(value)
return out
# CSV mapping field IDs to human-readable names
field_map_file = "field_ids_enriched.csv"
field_df = pd.read_csv(field_map_file, low_memory=False)
required_cols = {"field_instance", "var_name", "field_type"}
missing_cols = required_cols - set(field_df.columns)
if missing_cols:
raise ValueError(
f"{field_map_file} is missing required columns: {sorted(missing_cols)}"
)
field_df = field_df.dropna(subset=["field_instance", "var_name", "field_type"])
field_df["field_instance"] = field_df["field_instance"].astype(str)
field_df["var_name"] = field_df["var_name"].astype(str)
field_df["field_type"] = field_df["field_type"].astype(int)
# Map original field ID -> renamed output variable.
field_dict = dict(zip(field_df["field_instance"], field_df["var_name"]))
# Build source field groups from field_type.
basic_info_fields = _unique_preserve_order(
field_df.loc[field_df["field_type"] == 0, "var_name"].tolist()
)
assessment_fields = _unique_preserve_order(
field_df.loc[field_df["field_type"] == 1, "var_name"].tolist()
)
exposure_fields = _unique_preserve_order(
field_df.loc[field_df["field_type"] == 2, "var_name"].tolist()
)
# Keep only sex and enrollment time for the basic info table.
basic_info_fields = [
f for f in ["sex", "date_of_assessment"] if f in set(basic_info_fields)
]
# Fields needed for tabular extraction from raw CSV.
tabular_fields = _unique_preserve_order(
basic_info_fields + assessment_fields + exposure_fields
)
# TSV mapping field IDs to ICD10-related date columns
field_to_icd_map = "icd10_codes_mod.tsv"
# Date-like variables to be converted to offsets
date_vars = []
with open(field_to_icd_map, encoding="utf-8") as f: # Open ICD10 mapping
for line in f: # Iterate each mapping row
parts = line.strip().split() # Split on whitespace for TSV
if len(parts) >= 6: # Guard against malformed lines
# Map field ID to the date column name
field_dict[parts[0]] = parts[5]
date_vars.append(parts[5]) # Track date column names in order
for j in range(17): # Map up to 17 cancer entry slots (dates and types)
# Cancer diagnosis date slot j
field_dict[f"40005-{j}.0"] = f"cancer_date_{j}"
field_dict[f"40006-{j}.0"] = f"cancer_type_{j}" # Cancer type/code slot j
# Number of ICD-related date columns before adding extras
len_icd = len(date_vars)
date_vars.extend(
["Death", "date_of_assessment"] # Add outcome date and assessment date
+
# Add cancer date columns
[f"cancer_date_{j}" for j in range(17)]
)
labels_file = "labels.csv" # File listing label codes
label_dict = {} # Map code string -> integer label id
with open(labels_file, encoding="utf-8") as f: # Open labels file
for idx, line in enumerate(f): # Enumerate to assign incremental label IDs
parts = line.strip().split(" ") # Split by space
if parts and parts[0]: # Guard against empty lines
# Start labels from 1 to reserve 0 for padding, 1 for checkup
label_dict[parts[0]] = idx + 2
# Pre-build lookup: ICD/Death column name -> integer label for fast per-column extraction
icd_label_lookup = {}
for col_name in date_vars[:len_icd]:
if col_name in label_dict:
icd_label_lookup[col_name] = label_dict[col_name]
if "Death" in label_dict:
icd_label_lookup["Death"] = label_dict["Death"]
event_list = [] # Accumulator for event arrays across chunks
tabular_list = [] # Accumulator for tabular feature DataFrames across chunks
ukb_iterator = pd.read_csv( # Stream UK Biobank data in chunks
"ukb_data.csv",
sep=",",
chunksize=10000, # Stream file in manageable chunks to reduce memory footprint
# First column (participant ID) becomes DataFrame index
index_col=0,
low_memory=False, # Disable type inference optimization for consistent dtypes
)
# Iterate chunks with progress
for ukb_chunk in tqdm.tqdm(ukb_iterator, desc="Processing UK Biobank data"):
# Rename columns to friendly names
ukb_chunk = ukb_chunk.rename(columns=field_dict)
# Require sex to be present
ukb_chunk.dropna(subset=["sex"], inplace=True)
if ukb_chunk.empty:
continue
# Construct date of birth from year and month (day fixed to 1)
dob = pd.to_datetime(
pd.DataFrame(
{"year": ukb_chunk["year"], "month": ukb_chunk["month"], "day": 1}
),
errors="coerce",
)
# Use only date variables that actually exist in the current chunk
present_date_vars = [c for c in date_vars if c in ukb_chunk.columns]
# Convert date-like columns to day offsets from dob (per-column, avoids .apply overhead)
for col in present_date_vars:
ukb_chunk[col] = (
pd.to_datetime(
ukb_chunk[col], format="%Y-%m-%d", errors="coerce") - dob
).dt.days
# Append tabular features (use only columns that exist)
present_tabular_fields = [
c for c in tabular_fields if c in ukb_chunk.columns]
tabular_list.append(ukb_chunk[present_tabular_fields].copy())
# Extract ICD10 + Death events directly per column (avoids costly melt)
icd10_cols = present_date_vars[: len_icd + 1]
for col in icd10_cols:
if col not in icd_label_lookup:
continue
label_id = icd_label_lookup[col]
series = ukb_chunk[col]
valid_mask = series.notna()
if not valid_mask.any():
continue
event_list.append(
np.column_stack(
(
ukb_chunk.index[valid_mask].values,
series[valid_mask].values.astype(np.int64),
np.full(valid_mask.sum(), label_id, dtype=np.int64),
)
)
)
# Optimized cancer processing without wide_to_long
cancer_frames = []
for j in range(17):
d_col = f"cancer_date_{j}"
t_col = f"cancer_type_{j}"
if d_col in ukb_chunk.columns and t_col in ukb_chunk.columns:
# Filter rows where both date and type are present
mask = ukb_chunk[d_col].notna() & ukb_chunk[t_col].notna()
if mask.any():
subset_idx = ukb_chunk.index[mask]
subset_days = ukb_chunk.loc[mask, d_col]
subset_type = ukb_chunk.loc[mask, t_col]
# Map cancer type to label
# Use first 3 chars
cancer_codes = subset_type.str.slice(0, 3)
labels = cancer_codes.map(label_dict)
# Filter valid labels
valid_label_mask = labels.notna()
if valid_label_mask.any():
# Create array: eid, days, label
# Ensure types are correct for numpy
c_eids = subset_idx[valid_label_mask].values
c_days = subset_days[valid_label_mask].values
c_labels = labels[valid_label_mask].values
# Stack
chunk_cancer_data = np.column_stack(
(c_eids, c_days, c_labels))
cancer_frames.append(chunk_cancer_data)
if cancer_frames:
event_list.append(np.vstack(cancer_frames))
# Add checkup events with label=1 using date_of_assessment (already in days from dob)
if "date_of_assessment" in ukb_chunk.columns:
doa_series = ukb_chunk["date_of_assessment"].dropna()
if not doa_series.empty:
checkup_data = np.column_stack(
(
doa_series.index.values,
doa_series.values.astype(int),
np.ones(len(doa_series), dtype=int),
)
)
event_list.append(checkup_data)
# Combine tabular chunks
final_tabular = pd.concat(tabular_list, axis=0, ignore_index=False)
final_tabular.index.name = "eid" # Ensure index named consistently
data = np.vstack(event_list) # Stack all event arrays into one
# Sort by participant then day
data = data[np.lexsort((data[:, 1], data[:, 0]))]
# Keep only events with non-negative day offsets
data = data[data[:, 1] >= 0]
# Remove duplicate (participant_id, label) pairs keeping first occurrence (numpy-based).
_, first_idx = np.unique(data[:, [0, 2]], axis=0, return_index=True)
first_idx.sort() # Preserve original sorted order
data = data[first_idx]
# Store compactly using unsigned 32-bit integers
data = data.astype(np.uint32)
# Select eid in both data and tabular
valid_eids = np.intersect1d(data[:, 0], final_tabular.index)
data = data[np.isin(data[:, 0], valid_eids)]
final_tabular = final_tabular.loc[valid_eids]
final_tabular = final_tabular.convert_dtypes()
# Save basic sex information separately.
basic_info = final_tabular[["sex"]].copy()
basic_info.to_csv("ukb_basic_info.csv")
# Save unified other-information tokens. Missing values simply produce no token.
other_info_fields = _unique_preserve_order(
basic_info_fields + assessment_fields + exposure_fields
)
other_info, cate_types = _build_other_info_tokens(
final_tabular,
other_info_fields,
time_col="date_of_assessment",
)
np.save("ukb_other_info.npy", other_info)
cate_types.to_csv("cate_types.csv", index=False)
# Save event data
np.save("ukb_event_data.npy", data)

203
prepare_event_dates.py Normal file
View File

@@ -0,0 +1,203 @@
"""Create a compact calendar-dated disease-event index from ``ukb_data.csv``.
Unlike ``prepare_data.py``, this ETL does not create model-ready relative-time
sequences or other-information tokens. It writes one structured ``.npy`` file
with exactly three fields:
eid int64
event_date datetime64[D]
token int32
``token`` follows the existing ``labels.csv`` convention used by
``prepare_data.py``: padding=0, checkup=1 (not emitted here), and the first
label in ``labels.csv`` receives token 2. Each ``(eid, token)`` is deduplicated
to the first known event date.
The output is intended for calendar-indexed temperature and air-pollution
queries. It contains no date of birth, sex, covariates, or checkup events.
Usage
-----
python prepare_event_dates.py --output ukb_disease_event_dates.npy
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
try:
from tqdm import tqdm
except ImportError: # Keep the ETL runnable in minimal Python environments.
tqdm = None
EVENT_DTYPE = np.dtype(
[
("eid", "<i8"),
("event_date", "datetime64[D]"),
("token", "<i4"),
]
)
def load_label_tokens(labels_file: str | Path) -> dict[str, int]:
"""Return the label-code -> token mapping shared with ``prepare_data.py``."""
token_map: dict[str, int] = {}
with Path(labels_file).open(encoding="utf-8") as handle:
for index, line in enumerate(handle):
code = line.strip().split(" ", maxsplit=1)[0]
if code:
token_map[code] = index + 2
return token_map
def build_raw_column_map(
field_map_file: str | Path,
icd_map_file: str | Path,
) -> tuple[dict[str, str], list[str]]:
"""Build raw-column renames and the calendar-date event columns to inspect."""
field_df = pd.read_csv(field_map_file, low_memory=False)
required = {"field_instance", "var_name"}
missing = required - set(field_df.columns)
if missing:
raise ValueError(f"{field_map_file} is missing columns: {sorted(missing)}")
raw_to_name = dict(
zip(field_df["field_instance"].astype(str), field_df["var_name"].astype(str))
)
icd_date_columns: list[str] = []
with Path(icd_map_file).open(encoding="utf-8") as handle:
for line in handle:
parts = line.strip().split()
if len(parts) >= 6:
raw_to_name[parts[0]] = parts[5]
icd_date_columns.append(parts[5])
for slot in range(17):
raw_to_name[f"40005-{slot}.0"] = f"cancer_date_{slot}"
raw_to_name[f"40006-{slot}.0"] = f"cancer_type_{slot}"
return raw_to_name, icd_date_columns
def _records_from_icd_columns(
chunk: pd.DataFrame,
event_columns: list[str],
token_map: dict[str, int],
) -> list[pd.DataFrame]:
frames: list[pd.DataFrame] = []
for column in event_columns:
token = token_map.get(column)
if token is None or column not in chunk.columns:
continue
event_date = pd.to_datetime(chunk[column], format="%Y-%m-%d", errors="coerce")
valid = event_date.notna()
if valid.any():
frames.append(
pd.DataFrame(
{
"eid": chunk.index[valid].astype("int64"),
"event_date": event_date.loc[valid].to_numpy(),
"token": np.full(valid.sum(), token, dtype=np.int32),
}
)
)
return frames
def _records_from_cancer_columns(chunk: pd.DataFrame, token_map: dict[str, int]) -> list[pd.DataFrame]:
frames: list[pd.DataFrame] = []
for slot in range(17):
date_column = f"cancer_date_{slot}"
type_column = f"cancer_type_{slot}"
if date_column not in chunk.columns or type_column not in chunk.columns:
continue
event_date = pd.to_datetime(chunk[date_column], format="%Y-%m-%d", errors="coerce")
code = chunk[type_column].astype("string").str.slice(0, 3)
token = code.map(token_map)
valid = event_date.notna() & token.notna()
if valid.any():
frames.append(
pd.DataFrame(
{
"eid": chunk.index[valid].astype("int64"),
"event_date": event_date.loc[valid].to_numpy(),
"token": token.loc[valid].astype("int32").to_numpy(),
}
)
)
return frames
def prepare_event_dates(
*,
ukb_data_file: str | Path,
field_map_file: str | Path,
icd_map_file: str | Path,
labels_file: str | Path,
output_file: str | Path,
chunksize: int = 10_000,
) -> int:
"""Stream the raw UKB export, then write a sorted structured event array."""
token_map = load_label_tokens(labels_file)
raw_to_name, icd_date_columns = build_raw_column_map(field_map_file, icd_map_file)
event_columns = [*icd_date_columns, "Death"]
frames: list[pd.DataFrame] = []
reader = pd.read_csv(
ukb_data_file,
chunksize=chunksize,
index_col=0, # UKB participant ID / eid
low_memory=False,
)
chunk_iterator = tqdm(reader, desc="Extracting calendar-dated disease events") if tqdm else reader
for raw_chunk in chunk_iterator:
chunk = raw_chunk.rename(columns=raw_to_name)
frames.extend(_records_from_icd_columns(chunk, event_columns, token_map))
frames.extend(_records_from_cancer_columns(chunk, token_map))
if not frames:
result = np.empty(0, dtype=EVENT_DTYPE)
else:
events = pd.concat(frames, ignore_index=True)
events = events.dropna(subset=["eid", "event_date", "token"])
events = events.sort_values(["eid", "token", "event_date"], kind="stable")
# Match prepare_data.py: first occurrence of each disease/death token.
events = events.drop_duplicates(["eid", "token"], keep="first")
events = events.sort_values(["eid", "event_date", "token"], kind="stable")
result = np.empty(len(events), dtype=EVENT_DTYPE)
result["eid"] = events["eid"].to_numpy(dtype=np.int64)
result["event_date"] = events["event_date"].to_numpy(dtype="datetime64[D]")
result["token"] = events["token"].to_numpy(dtype=np.int32)
np.save(output_file, result)
return len(result)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ukb-data", default="ukb_data.csv")
parser.add_argument("--field-map", default="field_ids_enriched.csv")
parser.add_argument("--icd-map", default="icd10_codes_mod.tsv")
parser.add_argument("--labels", default="labels.csv")
parser.add_argument("--output", default="ukb_disease_event_dates.npy")
parser.add_argument("--chunksize", type=int, default=10_000)
args = parser.parse_args()
if args.chunksize <= 0:
raise ValueError("chunksize must be positive")
count = prepare_event_dates(
ukb_data_file=args.ukb_data,
field_map_file=args.field_map,
icd_map_file=args.icd_map,
labels_file=args.labels,
output_file=args.output,
chunksize=args.chunksize,
)
print(f"Wrote {count:,} first disease/death events to {args.output}")
if __name__ == "__main__":
main()

107
readouts.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from dataclasses import dataclass
import torch
import torch.nn as nn
@dataclass
class ReadoutOutput:
hidden: torch.Tensor
readout_mask: torch.Tensor
class TokenReadout(nn.Module):
def forward(
self,
hidden: torch.Tensor,
time_seq: torch.Tensor,
padding_mask: torch.Tensor,
readout_mask: torch.Tensor | None = None,
) -> ReadoutOutput:
mask = padding_mask if readout_mask is None else readout_mask
return ReadoutOutput(hidden=hidden, readout_mask=mask.bool())
class SameTimeGroupEndReadout(nn.Module):
def __init__(self, reduce: str = "mean"):
super().__init__()
if reduce not in {"mean", "sum"}:
raise ValueError("reduce must be either 'mean' or 'sum'")
self.reduce = reduce
def forward(
self,
hidden: torch.Tensor,
time_seq: torch.Tensor,
padding_mask: torch.Tensor,
readout_mask: torch.Tensor | None = None,
) -> ReadoutOutput:
if readout_mask is None:
next_is_new_time = torch.ones_like(padding_mask, dtype=torch.bool)
next_is_new_time[:, :-1] = time_seq[:, 1:] != time_seq[:, :-1]
readout_mask = padding_mask.bool() & next_is_new_time
else:
readout_mask = readout_mask.bool()
group_start = torch.ones_like(padding_mask, dtype=torch.bool)
group_start[:, 1:] = time_seq[:, 1:] != time_seq[:, :-1]
group_start = group_start & padding_mask.bool()
group_id = group_start.long().cumsum(dim=1) - 1
group_id = group_id.clamp_min(0)
max_groups = hidden.size(1)
group_sum = hidden.new_zeros(hidden.size(0), max_groups, hidden.size(2))
group_sum.scatter_add_(
1,
group_id.unsqueeze(-1).expand_as(hidden),
hidden * padding_mask.unsqueeze(-1).to(hidden.dtype),
)
if self.reduce == "mean":
group_count = hidden.new_zeros(hidden.size(0), max_groups, 1)
group_count.scatter_add_(
1,
group_id.unsqueeze(-1),
padding_mask.unsqueeze(-1).to(hidden.dtype),
)
group_sum = group_sum / group_count.clamp_min(1.0)
out = hidden.clone()
out[readout_mask] = group_sum.gather(
1,
group_id.unsqueeze(-1).expand_as(hidden),
)[readout_mask]
return ReadoutOutput(hidden=out, readout_mask=readout_mask)
class LastValidReadout(nn.Module):
def forward(
self,
hidden: torch.Tensor,
time_seq: torch.Tensor,
padding_mask: torch.Tensor,
readout_mask: torch.Tensor | None = None,
) -> ReadoutOutput:
batch_size, seq_len = padding_mask.shape
last_idx = padding_mask.long().sum(dim=1).clamp_min(1) - 1
out = hidden[torch.arange(batch_size, device=hidden.device), last_idx]
mask = torch.ones(batch_size, dtype=torch.bool, device=hidden.device)
return ReadoutOutput(hidden=out, readout_mask=mask)
def build_readout(name: str, **kwargs) -> nn.Module:
name = name.lower()
if name == "token":
return TokenReadout()
if name in {"same_time_group_end", "same_time"}:
return SameTimeGroupEndReadout(**kwargs)
if name == "last_valid":
return LastValidReadout()
raise ValueError(
"Unknown readout {!r}. Available: token, same_time_group_end, last_valid.".format(
name
)
)

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
numpy
pandas
torch
tqdm
scikit-learn

394
targets.py Normal file
View File

@@ -0,0 +1,394 @@
# targets.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import numpy as np
PAD_IDX = 0
CHECKUP_IDX = 1
NO_EVENT_IDX = 2
DAYS_PER_YEAR = 365.25
@dataclass(frozen=True)
class NextTokenTargets:
"""
Delphi2M-style next-token supervision targets.
Shapes:
input_events: (L,)
input_times_years: (L,)
target_events: (L,)
target_times_years:(L,)
where L = N - 1.
"""
input_events: np.ndarray
input_times_years: np.ndarray
target_events: np.ndarray
target_times_years: np.ndarray
@dataclass(frozen=True)
class UniqueTimeSetTargets:
"""
Unique-time set supervision targets.
Shapes:
readout_mask: (L,)
target_dt_unique: (L,)
target_multi_hot: (L, vocab_size)
where L = N - 1.
Only group-end positions can have readout_mask=True.
target_dt_unique is measured in years.
"""
readout_mask: np.ndarray
target_dt_unique: np.ndarray
target_multi_hot: np.ndarray
@dataclass(frozen=True)
class TargetPack:
"""
Combined target package for one patient sequence.
Contains both next-token targets and unique-time-set targets.
The training pipeline decides which one to use.
"""
next_token: NextTokenTargets
unique_time_set: UniqueTimeSetTargets
def _as_numpy_1d(
x: np.ndarray,
name: str,
dtype: np.dtype | type | None = None,
) -> np.ndarray:
arr = np.asarray(x)
if arr.ndim != 1:
raise ValueError(f"{name} must be 1D, got shape {arr.shape}")
if dtype is not None:
arr = arr.astype(dtype)
return arr
def validate_event_sequence(
labels: np.ndarray,
times_days: np.ndarray,
*,
require_sorted: bool = True,
) -> None:
"""
Validate one patient's event sequence.
labels:
1D integer label ids.
times_days:
1D event times in days.
require_sorted:
If True, raises when times_days is not non-decreasing.
"""
labels = _as_numpy_1d(labels, "labels")
times_days = _as_numpy_1d(times_days, "times_days")
if len(labels) != len(times_days):
raise ValueError(
f"labels and times_days must have the same length, "
f"got {len(labels)} and {len(times_days)}"
)
if len(labels) == 0:
raise ValueError("Empty event sequence is not valid.")
if np.any(labels < 0):
raise ValueError("labels contains negative ids.")
if not np.all(np.isfinite(times_days)):
raise ValueError("times_days contains non-finite values.")
if require_sorted and len(times_days) > 1:
if np.any(np.diff(times_days) < 0):
raise ValueError("times_days must be non-decreasing.")
def build_next_token_targets(
labels: np.ndarray,
times_days: np.ndarray,
*,
require_sorted: bool = True,
) -> NextTokenTargets:
"""
Build Delphi2M-style autoregressive next-token targets.
Given full sequence:
labels: [x0, x1, x2, ..., xN-1]
times_days: [t0, t1, t2, ..., tN-1]
returns:
input_events: [x0, x1, ..., xN-2]
input_times_years: [t0, t1, ..., tN-2] / 365.25
target_events: [x1, x2, ..., xN-1]
target_times_years: [t1, t2, ..., tN-1] / 365.25
This function does not ignore PAD/CHECKUP/NO_EVENT. Ignoring belongs to
the loss function because different objectives may use different ignore ids.
"""
labels = _as_numpy_1d(labels, "labels", np.int64)
times_days = _as_numpy_1d(times_days, "times_days", np.float32)
validate_event_sequence(labels, times_days, require_sorted=require_sorted)
if len(labels) < 2:
raise ValueError(
"Need at least two events to build next-token targets."
)
input_events = labels[:-1].astype(np.int64)
input_times_years = (times_days[:-1] / DAYS_PER_YEAR).astype(np.float32)
target_events = labels[1:].astype(np.int64)
target_times_years = (times_days[1:] / DAYS_PER_YEAR).astype(np.float32)
return NextTokenTargets(
input_events=input_events,
input_times_years=input_times_years,
target_events=target_events,
target_times_years=target_times_years,
)
def build_unique_time_set_targets(
labels: np.ndarray,
times_days: np.ndarray,
*,
vocab_size: int,
ignored_target_ids: Iterable[int] = (PAD_IDX, CHECKUP_IDX),
require_sorted: bool = True,
) -> UniqueTimeSetTargets:
"""
Build next-unique-time set targets.
This is the target construction used by your UTS / default mode.
For each input position i:
- only if i is the last token of its timestamp group;
- find the next distinct timestamp group;
- target is the set of valid event labels at that next timestamp.
Example:
t=49: X
t=50: A, B, C
t=51: D, E
Supervises:
X@49 -> {A, B, C}@50
group_end@50 -> {D, E}@51
It does NOT supervise:
A@50 -> B@50
B@50 -> C@50
Parameters
----------
labels:
Full event sequence labels, shape (N,).
times_days:
Full event sequence times in days, shape (N,).
vocab_size:
Size of output vocabulary.
ignored_target_ids:
Label ids that should not enter target_multi_hot.
Usually:
no no-event: {0, 1}
with no-event: {0, 1, 2}
For UTS, I recommend ignoring <NO_EVENT> unless explicitly testing it
as an event target.
Returns
-------
UniqueTimeSetTargets
"""
labels = _as_numpy_1d(labels, "labels", np.int64)
times_days = _as_numpy_1d(times_days, "times_days", np.float32)
validate_event_sequence(labels, times_days, require_sorted=require_sorted)
if vocab_size <= 0:
raise ValueError(f"vocab_size must be positive, got {vocab_size}")
if len(labels) < 2:
raise ValueError(
"Need at least two events to build unique-time-set targets."
)
input_len = len(labels) - 1
readout_mask = np.zeros(input_len, dtype=bool)
target_dt_unique = np.zeros(input_len, dtype=np.float32)
target_multi_hot = np.zeros((input_len, vocab_size), dtype=bool)
ignored = {int(x) for x in ignored_target_ids}
unique_times = np.unique(times_days)
time_to_group_idx = {t: i for i, t in enumerate(unique_times)}
group_indices = np.array([time_to_group_idx[t]
for t in times_days], dtype=np.int64)
for i in range(input_len):
current_group = group_indices[i]
is_last_in_group = (
i == input_len - 1
or group_indices[i + 1] != current_group
)
if not is_last_in_group:
continue
next_group_idx = current_group + 1
if next_group_idx >= len(unique_times):
continue
next_time = unique_times[next_group_idx]
next_labels = labels[group_indices == next_group_idx]
valid_next_labels: list[int] = []
for lab in next_labels:
lab_int = int(lab)
if lab_int in ignored:
continue
if lab_int < 0 or lab_int >= vocab_size:
continue
valid_next_labels.append(lab_int)
# If next timestamp contains only technical tokens, do not supervise UTS.
if len(valid_next_labels) == 0:
continue
readout_mask[i] = True
target_dt_unique[i] = float(next_time - times_days[i]) / DAYS_PER_YEAR
target_multi_hot[i, valid_next_labels] = True
return UniqueTimeSetTargets(
readout_mask=readout_mask,
target_dt_unique=target_dt_unique.astype(np.float32),
target_multi_hot=target_multi_hot,
)
def build_all_targets(
labels: np.ndarray,
times_days: np.ndarray,
*,
vocab_size: int,
ignored_uts_target_ids: Iterable[int] = (PAD_IDX, CHECKUP_IDX),
require_sorted: bool = True,
) -> TargetPack:
"""
Build both next-token targets and unique-time-set targets for one patient.
This is the function dataset.py should usually call during initialization.
The dataset can then store:
event_seq = target_pack.next_token.input_events
time_seq = target_pack.next_token.input_times_years
target_event_seq = target_pack.next_token.target_events
target_time_seq = target_pack.next_token.target_times_years
readout_mask = target_pack.unique_time_set.readout_mask
target_dt_unique = target_pack.unique_time_set.target_dt_unique
target_multi_hot = target_pack.unique_time_set.target_multi_hot
"""
next_token = build_next_token_targets(
labels=labels,
times_days=times_days,
require_sorted=require_sorted,
)
unique_time_set = build_unique_time_set_targets(
labels=labels,
times_days=times_days,
vocab_size=vocab_size,
ignored_target_ids=ignored_uts_target_ids,
require_sorted=require_sorted,
)
return TargetPack(
next_token=next_token,
unique_time_set=unique_time_set,
)
def get_group_end_mask_from_times(
times_days: np.ndarray,
*,
input_len: int | None = None,
) -> np.ndarray:
"""
Convenience utility for debugging.
Returns a bool mask indicating the last token of each same-time group
within the input sequence.
If input_len is None, uses len(times_days) - 1, matching model input length.
"""
times_days = _as_numpy_1d(times_days, "times_days", np.float32)
if input_len is None:
input_len = len(times_days) - 1
if input_len < 0 or input_len > len(times_days):
raise ValueError(
f"Invalid input_len={input_len} for sequence length {len(times_days)}"
)
out = np.zeros(input_len, dtype=bool)
for i in range(input_len):
is_last_in_group = (
i == input_len - 1
or times_days[i + 1] != times_days[i]
)
out[i] = is_last_in_group
return out
def summarize_targets(
target_pack: TargetPack,
) -> dict[str, int | float]:
"""
Small debugging helper for logging.
"""
nt = target_pack.next_token
uts = target_pack.unique_time_set
n_tokens = int(len(nt.input_events))
n_readout = int(uts.readout_mask.sum())
n_positive_labels = int(uts.target_multi_hot.sum())
mean_set_size = (
float(n_positive_labels / n_readout)
if n_readout > 0
else 0.0
)
return {
"n_input_tokens": n_tokens,
"n_uts_readouts": n_readout,
"n_uts_positive_labels": n_positive_labels,
"mean_uts_set_size": mean_set_size,
}

667
train_next_step.py Normal file
View File

@@ -0,0 +1,667 @@
"""
Train DeepHealth with next-token / next-time-point supervision.
The next-step dataset uses observed event histories, including CHECKUP state
tokens, plus optional gap <NO_EVENT> imputation. UTS training reads out only
same-time group ends.
"""
from __future__ import annotations
import argparse
import json
import logging
import math
import time
from pathlib import Path
from typing import Any, Dict
import numpy as np
import torch
from torch.nn.utils import clip_grad_norm_
from torch.optim import AdamW
from torch.utils.data import DataLoader, RandomSampler
from tqdm.auto import tqdm
from dataset import HealthDataset, collate_fn
from losses import build_loss
from models import DeepHealth, DeepHealthOutput
from readouts import build_readout
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX
from train_util import (
configure_torch_for_training,
create_unique_run_dir,
format_extra_info_types,
load_extra_info_types_file,
resolve_device,
save_checkpoint,
save_config,
set_optimizer_lr,
set_seed,
setup_logging,
split_dataset,
split_dataset_by_eid_files,
)
MODEL_INPUT_KEYS = (
"event_seq",
"time_seq",
"sex",
"padding_mask",
"other_type",
"other_value",
"other_value_kind",
"other_time",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Train DeepHealth with next-token/point supervision")
parser.add_argument("--data_prefix", type=str, default="ukb")
parser.add_argument("--labels_file", type=str, default="labels.csv")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--extra_info_types_file", type=str, default=None)
parser.add_argument("--no_event_interval_years", type=float, default=5.0)
parser.add_argument("--include_no_event_in_uts_target", action="store_true")
parser.add_argument("--train_ratio", type=float, default=0.7)
parser.add_argument("--val_ratio", type=float, default=0.15)
parser.add_argument("--test_ratio", type=float, default=0.15)
parser.add_argument("--train_eid_file", type=str, default="ukb_train_eid.csv")
parser.add_argument("--val_eid_file", type=str, default="ukb_val_eid.csv")
parser.add_argument("--test_eid_file", type=str, default="ukb_test_eid.csv")
parser.add_argument("--n_embd", type=int, default=120)
parser.add_argument("--n_head", type=int, default=10)
parser.add_argument("--n_hist_layer", type=int, default=12)
parser.add_argument("--n_tab_layer", type=int, default=4)
parser.add_argument("--n_bins", type=int, default=16)
parser.add_argument("--extra_pool_reduce", type=str, default="mean",
choices=["mean", "sum"])
parser.add_argument("--time_mode", type=str, default="relative",
choices=["relative", "absolute"])
parser.add_argument("--dropout", type=float, default=0.0)
parser.add_argument("--target_mode", type=str, default="uts",
choices=["delphi2m", "uts"])
parser.add_argument("--readout_name", type=str, default=None,
choices=["token", "same_time_group_end", "last_valid"])
parser.add_argument("--readout_reduce", type=str, default="mean",
choices=["mean", "sum"])
parser.add_argument("--t_min", type=float, default=0.0027378507871321013)
parser.add_argument("--max_exp_input", type=float, default=60.0)
parser.add_argument("--ce_weight", type=float, default=1.0)
parser.add_argument("--time_weight", type=float, default=1.0)
parser.add_argument("--ignore_no_event_in_delphi2m", action="store_true")
parser.add_argument("--batch_size", type=int, default=128)
parser.add_argument("--base_lr", type=float, default=3e-4)
parser.add_argument("--weight_decay", type=float, default=0.1)
parser.add_argument("--betas", type=float, nargs=2, default=(0.9, 0.99))
parser.add_argument("--grad_clip", type=float, default=1.0)
parser.add_argument("--max_epochs", type=int, default=200)
parser.add_argument("--warmup_epochs", type=int, default=10)
parser.add_argument("--patience", type=int, default=15)
parser.add_argument("--min_lr_ratio", type=float, default=0.1)
parser.add_argument("--num_workers", type=int, default=4)
parser.add_argument("--device", type=str, default="cuda")
parser.add_argument("--progress_interval", type=int, default=20)
args = parser.parse_args()
use_eid_split = all(
getattr(args, name)
for name in ("train_eid_file", "val_eid_file", "test_eid_file")
)
if not use_eid_split and not np.isclose(args.train_ratio + args.val_ratio + args.test_ratio, 1.0):
raise ValueError("train_ratio + val_ratio + test_ratio must equal 1.0")
if args.target_mode == "uts":
args.readout_name = args.readout_name or "same_time_group_end"
args.include_no_event_in_uts_target = True
else:
args.readout_name = args.readout_name or "token"
args.extra_info_types = (
load_extra_info_types_file(args.extra_info_types_file)
if args.extra_info_types_file is not None
else None
)
return args
def get_lr(epoch: int, args: argparse.Namespace, 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 build_model(args: argparse.Namespace, dataset: HealthDataset) -> DeepHealth:
return DeepHealth(
vocab_size=dataset.vocab_size,
n_embd=args.n_embd,
n_head=args.n_head,
n_hist_layer=args.n_hist_layer,
n_tab_layer=args.n_tab_layer,
n_types=dataset.n_types,
n_cont_types=dataset.n_cont_types,
n_categories=dataset.n_categories,
cont_type_ids=dataset.cont_type_ids,
n_bins=args.n_bins,
extra_pool_reduce=args.extra_pool_reduce,
target_mode="next_token",
time_mode=args.time_mode,
dist_mode="exponential",
dropout=args.dropout,
)
def build_next_step_readout(args: argparse.Namespace):
if args.readout_name == "same_time_group_end":
return build_readout("same_time_group_end", reduce=args.readout_reduce)
return build_readout(args.readout_name)
def build_next_step_loss(args: argparse.Namespace):
if args.target_mode == "delphi2m":
ignored_tokens = {PAD_IDX, CHECKUP_IDX}
if args.ignore_no_event_in_delphi2m:
ignored_tokens.add(NO_EVENT_IDX)
return build_loss(
"delphi2m",
ignored_tokens=ignored_tokens,
t_min=args.t_min,
max_exp_input=args.max_exp_input,
ce_weight=args.ce_weight,
time_weight=args.time_weight,
)
return build_loss(
"uts",
ignored_idx={PAD_IDX, CHECKUP_IDX},
t_min=args.t_min,
max_exp_input=args.max_exp_input,
)
def build_augmented_next_step_targets(
batch_cpu: Dict[str, torch.Tensor],
model_out: DeepHealthOutput,
include_uts_targets: bool,
) -> Dict[str, torch.Tensor]:
hidden_len = model_out.hidden.size(1)
event_len = int(model_out.event_len)
extra_len = hidden_len - event_len
device = model_out.hidden.device
non_blocking = device.type == "cuda"
if extra_len <= 0:
targets = {
"target_event_seq": batch_cpu["target_event_seq"].to(device, non_blocking=non_blocking),
"target_time_seq": batch_cpu["target_time_seq"].to(device, non_blocking=non_blocking),
"readout_mask": batch_cpu["readout_mask"].to(device, non_blocking=non_blocking),
}
if include_uts_targets:
targets["target_dt_unique"] = batch_cpu["target_dt_unique"].to(
device, non_blocking=non_blocking
)
targets["target_multi_hot"] = batch_cpu["target_multi_hot"].to(
device, non_blocking=non_blocking
)
return targets
bsz = batch_cpu["target_event_seq"].size(0)
vocab_size = (
batch_cpu["target_multi_hot"].size(2)
if include_uts_targets
else None
)
other_valid = batch_cpu["other_type"] > 0
extra_time = batch_cpu["other_time"].new_zeros(bsz, extra_len)
extra_mask = torch.zeros(bsz, extra_len, dtype=torch.bool)
for b in range(bsz):
unique_time = torch.unique(batch_cpu["other_time"][b, other_valid[b]], sorted=True)
n_time = min(int(unique_time.numel()), extra_len)
if n_time > 0:
extra_time[b, :n_time] = unique_time[:n_time]
extra_mask[b, :n_time] = True
target_event_seq = torch.cat(
[
batch_cpu["target_event_seq"],
torch.full(
(bsz, extra_len),
PAD_IDX,
dtype=batch_cpu["target_event_seq"].dtype,
),
],
dim=1,
)
target_time_seq = torch.cat(
[
batch_cpu["target_time_seq"],
torch.zeros(
bsz,
extra_len,
dtype=batch_cpu["target_time_seq"].dtype,
),
],
dim=1,
)
readout_mask = torch.cat([batch_cpu["readout_mask"], extra_mask], dim=1)
target_dt_unique = None
target_multi_hot = None
if include_uts_targets:
target_dt_unique = torch.cat(
[
batch_cpu["target_dt_unique"],
torch.zeros(
bsz,
extra_len,
dtype=batch_cpu["target_dt_unique"].dtype,
),
],
dim=1,
)
target_multi_hot = torch.cat(
[
batch_cpu["target_multi_hot"],
torch.zeros(
bsz,
extra_len,
vocab_size,
dtype=batch_cpu["target_multi_hot"].dtype,
),
],
dim=1,
)
for b in range(bsz):
valid_event = batch_cpu["padding_mask"][b].bool()
if not valid_event.any():
continue
n_event = int(valid_event.sum().item())
events = torch.cat(
[
batch_cpu["event_seq"][b, :n_event],
batch_cpu["target_event_seq"][b, n_event - 1:n_event],
]
)
times = torch.cat(
[
batch_cpu["time_seq"][b, :n_event],
batch_cpu["target_time_seq"][b, n_event - 1:n_event],
]
)
valid_full = events > PAD_IDX
events = events[valid_full]
times = times[valid_full]
if events.numel() == 0:
continue
for j in range(extra_len):
if not bool(extra_mask[b, j]):
continue
pos = event_len + j
t = extra_time[b, j]
future = times > t
if not future.any():
readout_mask[b, pos] = False
continue
first_idx = int(torch.nonzero(future, as_tuple=False)[0].item())
next_time = times[first_idx]
next_event = events[first_idx]
target_event_seq[b, pos] = next_event
target_time_seq[b, pos] = next_time
if not include_uts_targets:
continue
same_next_time = times == next_time
next_events = events[same_next_time]
valid_next_events = next_events[
(next_events > PAD_IDX) & (next_events < vocab_size)
].long()
if valid_next_events.numel() == 0:
readout_mask[b, pos] = False
continue
target_multi_hot[b, pos, valid_next_events] = True
target_dt_unique[b, pos] = next_time - t
targets = {
"target_event_seq": target_event_seq.to(device, non_blocking=non_blocking),
"target_time_seq": target_time_seq.to(device, non_blocking=non_blocking),
"readout_mask": readout_mask.to(device, non_blocking=non_blocking),
}
if include_uts_targets:
targets["target_dt_unique"] = target_dt_unique.to(device, non_blocking=non_blocking)
targets["target_multi_hot"] = target_multi_hot.to(device, non_blocking=non_blocking)
return targets
def compute_next_step_loss(
args: argparse.Namespace,
model: DeepHealth,
readout,
criterion,
batch: Dict[str, torch.Tensor],
device: torch.device,
) -> tuple[torch.Tensor, Dict[str, torch.Tensor]]:
batch_cpu = batch
batch = move_batch_to_device(
{key: batch_cpu[key] for key in MODEL_INPUT_KEYS},
device,
)
model_out = model(
event_seq=batch["event_seq"],
time_seq=batch["time_seq"],
sex=batch["sex"],
padding_mask=batch["padding_mask"],
other_type=batch["other_type"],
other_value=batch["other_value"],
other_value_kind=batch["other_value_kind"],
other_time=batch["other_time"],
target_mode="next_token",
return_output=True,
)
if not isinstance(model_out, DeepHealthOutput):
raise TypeError("DeepHealth return_output=True must return DeepHealthOutput")
targets = build_augmented_next_step_targets(
batch_cpu=batch_cpu,
model_out=model_out,
include_uts_targets=args.target_mode == "uts",
)
readout_out = readout(
hidden=model_out.hidden,
time_seq=model_out.time_seq,
padding_mask=model_out.padding_mask,
readout_mask=targets["readout_mask"]
if args.readout_name == "same_time_group_end"
else None,
)
logits = model.calc_risk(readout_out.hidden)
if args.target_mode == "delphi2m":
loss, parts = criterion(
logits=logits,
target_events=targets["target_event_seq"],
target_times=targets["target_time_seq"],
current_times=model_out.time_seq,
padding_mask=readout_out.readout_mask,
return_components=True,
)
else:
loss, parts = criterion(
logits=logits,
target_multi_hot=targets["target_multi_hot"],
target_dt_unique=targets["target_dt_unique"],
readout_mask=readout_out.readout_mask,
return_components=True,
)
if not torch.isfinite(loss):
raise RuntimeError(f"Loss is not finite: {float(loss.detach().cpu())}")
return loss, parts
def run_epoch(
logger: logging.Logger,
args: argparse.Namespace,
model: DeepHealth,
readout,
criterion,
loader: DataLoader,
optimizer: AdamW | None,
device: torch.device,
is_train: bool,
) -> float:
model.train(is_train)
readout.train(is_train)
total = torch.zeros((), device=device)
n_batches = 0
skipped = 0
parts_sum: Dict[str, torch.Tensor] = {}
desc = "train" if is_train else "val"
progress_interval = max(1, int(args.progress_interval))
progress = tqdm(loader, desc=desc, leave=False, dynamic_ncols=True)
for batch_idx, batch in enumerate(progress):
try:
loss, parts = compute_next_step_loss(args, model, readout, criterion, batch, device)
if is_train:
if optimizer is None:
raise ValueError("optimizer is required for training")
optimizer.zero_grad(set_to_none=True)
loss.backward()
if args.grad_clip > 0:
clip_grad_norm_(model.parameters(), args.grad_clip)
optimizer.step()
total = total + loss.detach()
n_batches += 1
for name, value in parts.items():
parts_sum[name] = parts_sum.get(name, torch.zeros((), device=device)) + value.detach()
if (batch_idx + 1) % progress_interval == 0:
avg = total / max(1, n_batches)
postfix = {
"loss": f"{float(loss.detach().cpu()):.4f}",
"avg": f"{float(avg.detach().cpu()):.4f}",
"skipped": skipped,
}
for name, value in parts_sum.items():
postfix[name] = f"{float((value / max(1, n_batches)).detach().cpu()):.4f}"
progress.set_postfix(postfix)
except RuntimeError as exc:
if "Loss is not finite" not in str(exc):
raise
skipped += 1
logger.warning(f"Batch {batch_idx} skipped: {str(exc)[:120]}")
if skipped:
logger.info(f"Skipped {skipped} batches due to non-finite loss")
return float((total / max(1, n_batches)).detach().cpu()) if n_batches else float("inf")
def build_metadata(
args: argparse.Namespace,
dataset: HealthDataset,
run_name: str,
train_subset,
val_subset,
test_subset,
) -> Dict[str, Any]:
return {
"run_name": run_name,
"dataset_class": "NextStepHealthDataset",
"collate_fn": "next_step_collate_fn",
"model_class": "DeepHealth",
"model_target_mode": "next_token",
"target_mode": args.target_mode,
"dist_mode": "exponential",
"extra_info_types_file": (
Path(args.extra_info_types_file).name
if args.extra_info_types_file is not None
else None
),
"extra_info_types": [int(x) for x in dataset.extra_info_types],
"dataset_metadata": {
"vocab_size": int(dataset.vocab_size),
"n_types": int(dataset.n_types),
"n_cont_types": int(dataset.n_cont_types),
"n_categories": int(dataset.n_categories),
"cont_type_ids": [int(x) for x in dataset.cont_type_ids],
"extra_info_types": [int(x) for x in dataset.extra_info_types],
},
"split_sizes": {
"train": int(len(train_subset)),
"val": int(len(val_subset)),
"test": int(len(test_subset)),
},
"resolved_readout_name": args.readout_name,
"resolved_loss_name": args.target_mode,
}
def main() -> None:
args = parse_args()
set_seed(args.seed)
device = resolve_device(args.device)
configure_torch_for_training(device)
run_dir, run_name = create_unique_run_dir(
lambda timestamp: (
f"{args.time_mode}_exponential_next_token_{args.target_mode}_"
f"gap_{args.no_event_interval_years:g}y_{timestamp}"
)
)
logger = setup_logging(run_dir)
logger.info(f"Starting next-step training run: {run_name}")
logger.info(f"Device: {device}")
logger.info(f"extra_info_types: {format_extra_info_types(args.extra_info_types)}")
logger.info(f"readout={args.readout_name}, target_mode={args.target_mode}")
dataset = HealthDataset(
data_prefix=args.data_prefix,
labels_file=args.labels_file,
no_event_interval_years=args.no_event_interval_years,
include_no_event_in_uts_target=args.include_no_event_in_uts_target,
extra_info_types=args.extra_info_types,
)
if args.train_eid_file and args.val_eid_file and args.test_eid_file:
train_subset, val_subset, test_subset = split_dataset_by_eid_files(
dataset=dataset,
train_eid_file=args.train_eid_file,
val_eid_file=args.val_eid_file,
test_eid_file=args.test_eid_file,
)
logger.info(
"Using eid split files: "
f"train={args.train_eid_file}, val={args.val_eid_file}, test={args.test_eid_file}"
)
else:
train_subset, val_subset, test_subset = split_dataset(
dataset=dataset,
train_ratio=args.train_ratio,
val_ratio=args.val_ratio,
test_ratio=args.test_ratio,
seed=args.seed,
)
logger.info(
f"Using random ratio split: train={args.train_ratio}, "
f"val={args.val_ratio}, test={args.test_ratio}, seed={args.seed}"
)
logger.info(
f"Samples: train={len(train_subset)}, val={len(val_subset)}, test={len(test_subset)}"
)
train_loader = DataLoader(
train_subset,
batch_size=args.batch_size,
sampler=RandomSampler(train_subset, generator=torch.Generator().manual_seed(args.seed)),
collate_fn=collate_fn,
num_workers=args.num_workers,
pin_memory=device.type == "cuda",
persistent_workers=args.num_workers > 0,
prefetch_factor=2 if args.num_workers > 0 else None,
)
val_loader = DataLoader(
val_subset,
batch_size=args.batch_size,
shuffle=False,
collate_fn=collate_fn,
num_workers=args.num_workers,
pin_memory=device.type == "cuda",
persistent_workers=args.num_workers > 0,
prefetch_factor=2 if args.num_workers > 0 else None,
)
test_loader = DataLoader(
test_subset,
batch_size=args.batch_size,
shuffle=False,
collate_fn=collate_fn,
num_workers=args.num_workers,
pin_memory=device.type == "cuda",
persistent_workers=args.num_workers > 0,
prefetch_factor=2 if args.num_workers > 0 else None,
)
model = build_model(args, dataset).to(device)
readout = build_next_step_readout(args).to(device)
criterion = build_next_step_loss(args)
optimizer = AdamW(
model.parameters(),
lr=args.base_lr,
betas=tuple(args.betas),
weight_decay=args.weight_decay,
)
adaptive_lr = args.base_lr * math.sqrt(args.batch_size / 128)
save_config(
args,
run_dir / "train_config.json",
extra=build_metadata(args, dataset, run_name, train_subset, val_subset, test_subset),
)
best_val = float("inf")
patience = 0
history = []
best_model_path = run_dir / "best_model.pt"
start = time.time()
for epoch in range(args.max_epochs):
lr = get_lr(epoch, args, adaptive_lr)
set_optimizer_lr(optimizer, lr)
train_loss = run_epoch(logger, args, model, readout, criterion, train_loader, optimizer, device, True)
with torch.no_grad():
val_loss = run_epoch(logger, args, model, readout, criterion, val_loader, None, device, False)
is_best = val_loss < best_val
if is_best:
best_val = val_loss
patience = 0
save_checkpoint(model, best_model_path)
else:
patience += 1
logger.info(
f"Epoch {epoch + 1}/{args.max_epochs} | lr={lr:.6f} | "
f"train_loss={train_loss:.6f} | val_loss={val_loss:.6f} | "
f"best_val_loss={best_val:.6f} | patience={patience}/{args.patience} | "
f"elapsed={time.time() - start:.1f}s"
)
history.append({
"epoch": epoch + 1,
"lr": lr,
"train_loss": train_loss,
"val_loss": val_loss,
"best_val_loss": best_val,
"is_best": int(is_best),
})
if patience >= args.patience:
logger.info(f"Early stopping triggered at epoch {epoch + 1}")
break
with (run_dir / "history.json").open("w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
logger.info("Evaluating best model on next-step test split...")
model.load_state_dict(torch.load(best_model_path, map_location=device))
with torch.no_grad():
test_loss = run_epoch(logger, args, model, readout, criterion, test_loader, None, device, False)
logger.info(f"Test loss: {test_loss:.6f}")
logger.info(f"Best checkpoint: {best_model_path}")
if __name__ == "__main__":
main()

329
train_util.py Normal file
View File

@@ -0,0 +1,329 @@
from __future__ import annotations
import json
import logging
import sys
import time
import csv
from datetime import datetime
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
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 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 build_optimizer(args: Any, model: DeepHealth) -> AdamW:
return AdamW(
model.parameters(),
lr=args.base_lr,
betas=tuple(args.betas),
weight_decay=args.weight_decay,
)
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")