Compare commits

3 Commits

Author SHA1 Message Date
b13db5e407 Unify FFN and TrajMixer model architectures 2026-07-25 12:59:09 +08:00
4526191fe1 Report AUCs in Delphi2M format 2026-07-25 11:18:24 +08:00
3af823f2e1 Fix RBF time-bias initialization 2026-07-24 14:43:59 +08:00
21 changed files with 1194 additions and 650 deletions

64
MODEL_ARCHITECTURES.md Normal file
View File

@@ -0,0 +1,64 @@
# Model architectures
DeepHealth uses one codebase for both supported history-block architectures.
Select the architecture explicitly when starting a training run:
| `model_architecture` | History block | Checkpoint fingerprint |
| --- | --- | --- |
| `transformer_ffn_v1` | Temporal attention + SwiGLU FFN | `blocks.*.mlp.w1/w2/w3` and `blocks.*.ln2` |
| `traj_mixer_v5` | Temporal attention + TrajMixer | `blocks.*.mlp.intra_*`, `gate_proj`, and `output_proj` |
`transformer_ffn_v1` is the CLI default; pass `traj_mixer_v5` explicitly for
TrajMixer runs.
## Training
Next-step example:
```powershell
python train_next_step.py --model_architecture traj_mixer_v5 --n_layer 12
```
All-future example:
```powershell
python train_all_future.py --model_architecture transformer_ffn_v1 --n_layer 12
```
New runs are separated by architecture:
```text
runs/
transformer_ffn_v1/
<run_name>/
traj_mixer_v5/
<run_name>/
```
Use `--runs_root` to place this structure under a different root. Existing run
directories are not moved or renamed.
Each generated `train_config.json` records `model_architecture`, total parameter
count, and trainable parameter count.
Both training entry points use the single `--n_layer` option to set the number
of history backbone blocks. The same value is passed to `DeepHealth.n_layer`
and saved as `n_layer` in `train_config.json`; it must be at least 1.
## Architecture validation
Evaluation resolves the architecture before constructing the model and always
loads weights with `strict=True`.
- Every config must include an explicit `model_architecture` marker.
- Checkpoint fingerprints are used to validate that the selected architecture
matches the stored weights.
- A config marker that conflicts with the checkpoint fingerprint raises an
error instead of silently choosing one architecture.
- Unsupported historical TrajMixer markers such as `traj_mixer_v2`,
`traj_mixer_v3`, and `traj_mixer_v4` are rejected.
- Checkpoints and configs created before architecture markers were introduced
are intentionally unsupported.
Project code should use the architecture factory rather than instantiate a
history block directly.

View File

@@ -1,333 +0,0 @@
# TrajMixer Block 最终设计方案
> 状态:**Frozen implementation baseline**
>
> 版本:**v1.0**
>
> 固化日期:**2026-07-22**
本文档是 TrajMixer 后续实现与实验的唯一结构基线。除显式标记为消融项的配置外,所有实现均应遵循本文档;若结构发生变化,应先更新版本和实验记录。
## 1. 目标
在保持原始 Delphi Transformer Attention 结构不变的前提下,用轻量、可并行的轨迹交互模块替换 FFN。
保持不变的组件包括:
- 原始 causal mask
- 原始 TimeRoPE / Relative Time Attention Bias
- 原始 Multi-Head Attention包括 \(W_Q/W_K/W_V/W_O\)
- 原始序列建模与训练目标。
TrajMixer 不修改 Attention只替换每个 Transformer block 中的 FFN residual branch。
## 2. Block 总体结构
概念结构:
```text
PreNorm Causal Multi-Head Attention
→ Residual
→ Standard Mixer PreNorm
→ Group-wise Feature Alignment
→ SwiGLU Cross-Group Mixer
→ Residual
```
完整计算为:
\[
U = X^{(l)} + \operatorname{Dropout}\!\left(
\operatorname{CausalMHA}\left(
\operatorname{LN}_{\mathrm{attn}}(X^{(l)}),
\text{time information}
\right)\right),
\]
\[
N = \operatorname{LN}_{\mathrm{mixer}}(U),
\]
\[
\Delta = \operatorname{TrajMixer}(N),
\]
\[
X^{(l+1)} = U + \operatorname{Dropout}(\Delta).
\]
首版中的 \(\operatorname{LN}_{\mathrm{mixer}}\) 是作用于完整 \(d=120\) 维 residual representation 的标准 LayerNorm。
## 3. Latent Trajectory Group 定义
Attention 输出经过 \(W_O\) 后仍是标准 residual representation
\[
N\in\mathbb{R}^{B\times L\times d},\qquad d=120.
\]
将 hidden dimension 划分为与 Attention head 数量相同的 group 数量:
\[
n_{\mathrm{group}}:=n_{\mathrm{head}}=10,
\qquad d_{\mathrm{group}}=\frac{d}{n_{\mathrm{head}}}=12,
\]
`n_group` 不再是独立超参数,代码统一使用 `n_head` 确定 residual group 数量。二者只共享数量;这些 residual groups 在语义和张量来源上仍不等同于原始 Attention heads。
并 reshape 为:
\[
N_{\mathrm{group}}in
\mathbb{R}^{B\times L\times n_{\mathrm{group}}\times d_{\mathrm{group}}}.
\]
这些 group 是 residual space 中的 **latent trajectory groups**,不等同于原始 Attention heads。本文中的 group、trajectory group 均指这一 residual-channel partition。
## 4. Group-wise Feature Alignment
为缓解不同 group 内部坐标不对齐的问题,每个 group 使用独立的小矩阵:
\[
B_i\in\mathbb{R}^{d_{\mathrm{group}}\times d_{\mathrm{group}}},
\qquad i=1,\ldots,n_{\mathrm{group}}.
\]
对每个 group 内的特征进行可学习对齐:
\[
Z_{b,t,i,:}=N_{\mathrm{group},b,t,i,:}B_i.
\]
因此:
\[
Z\in
\mathbb{R}^{B\times L\times n_{\mathrm{group}}\times d_{\mathrm{group}}}.
\]
首版实现约定:
- \(B_i\) 不带 bias
- \(B_i\) 使用单位矩阵初始化;
- Alignment 只作用于 Mixer residual branch不改变 Attention residual stream
- 首版不增加逆变换或额外的 group 内输出投影。
Alignment 每层权重参数量为:
\[
n_{\mathrm{group}}d_{\mathrm{group}}^2
=10\times12^2
=1{,}440.
\]
## 5. SwiGLU Cross-Group Mixer
Mixer 只沿 group 维度交互,不沿序列维度交互,因此不会引入时间递归或未来信息泄漏。
对于每个 group 内特征维度:
\[
r=1,\ldots,d_{\mathrm{group}},
\]
定义:
\[
A_g^{(r)},A_v^{(r)}
\in\mathbb{R}^{n_{\mathrm{group}}\times h_{\mathrm{group}}},
\]
\[
A_o^{(r)}
\in\mathbb{R}^{h_{\mathrm{group}}\times n_{\mathrm{group}}}.
\]
隐藏宽度不再独立配置,固定为:
\[
h_{\mathrm{group}}=4n_{\mathrm{head}}
=4n_{\mathrm{group}}.
\]
当前 \(n_{\mathrm{head}}=10\),因此 \(h_{\mathrm{group}}=40\)。
对固定的 batch、时间位置和内部特征维度 \(r\),将:
\[
Z_{b,t,:,r}\in\mathbb{R}^{n_{\mathrm{group}}}
\]
视为 row vector计算
\[
G_{b,t,:,r}=Z_{b,t,:,r}A_g^{(r)},
\]
\[
V_{b,t,:,r}=Z_{b,t,:,r}A_v^{(r)},
\]
\[
M_{b,t,:,r}=\operatorname{SiLU}(G_{b,t,:,r})\odot V_{b,t,:,r},
\]
\[
Y_{b,t,:,r}=M_{b,t,:,r}A_o^{(r)}.
\]
其中:
- gate 分支控制信息写入;
- value 分支提供交互内容;
- output matrix 将隐藏 group 表示投影回原始 group 数量;
- hidden group 表示固定扩展为 group 数量的 4 倍。
所有 \(r\) 的输出组合为:
\[
Y\in
\mathbb{R}^{B\times L\times n_{\mathrm{group}}\times d_{\mathrm{group}}},
\]
再 reshape 为:
\[
\Delta\in\mathbb{R}^{B\times L\times d}.
\]
## 6. 参数张量与无歧义索引
建议的实现存储形状为:
```text
group_align: [n_group, d_group, d_group]
gate_proj: [d_group, n_group, hidden_group]
value_proj: [d_group, n_group, hidden_group]
output_proj: [d_group, hidden_group, n_group]
```
对应的索引公式为:
\[
G_{b,t,q,r}
=\sum_i Z_{b,t,i,r}\,A_{g,r,i,q},
\]
\[
V_{b,t,q,r}
=\sum_i Z_{b,t,i,r}\,A_{v,r,i,q},
\]
\[
Y_{b,t,i,r}
=\sum_q
\left[\operatorname{SiLU}(G_{b,t,q,r})V_{b,t,q,r}\right]
A_{o,r,q,i}.
\]
首版的三个 Mixer projection 均不带 bias。
## 7. Mixer Hidden Width 与参数量
Mixer 的 group 维变换为:
\[
n_{\mathrm{group}}
\rightarrow
h_{\mathrm{group}}
\rightarrow
n_{\mathrm{group}}.
\]
固定 \(h_{\mathrm{group}}=4n_{\mathrm{group}}=40\) 时Mixer 每层权重参数量为:
\[
3d_{\mathrm{group}}n_{\mathrm{group}}h_{\mathrm{group}}
=3\times12\times10\times40
=14{,}400.
\]
加上 Group Feature Alignment 后TrajMixer residual branch 每层共有:
\[
14{,}400+1{,}440=15{,}840
\]
个主要权重参数。作为对照,原始 \(120\rightarrow480\rightarrow120\) FFN 每层约有 115,800 个参数。
参数对照口径说明:上面的 115,800 对应结构方案中的标准两层 FFN。当前代码库在 TrajMixer 替换前实际使用的是隐藏宽度 300 的全维度 SwiGLUgate/value/output 三个线性层),每层共有 108,720 个参数(含 bias。代码实验和 checkpoint 参数量比较必须以 108,720 作为历史实现基线,不能与概念方案中的标准 FFN 参数量混用。
## 8. LayerNorm 基线与消融
为保持与原始 Transformer 的可比性,首版固定使用:
```text
原始 FFN baselineFFN + 标准 LayerNorm
TrajMixer baselineMixer + 标准 LayerNorm
```
以下配置不属于首版主实验,只作为独立消融:
```text
Mixer + Group-wise LayerNorm
```
不得将 Group-wise LayerNorm 的结果直接作为“仅替换 FFN”的对照结果。
## 9. 初始化
首版初始化约定:
- Group Alignment \(B_i\):单位矩阵初始化;
- \(A_g/A_v\)Xavier uniform 初始化;
- \(A_o\):均值为 0、标准差为 \(10^{-3}\) 的正态初始化;
- Dropout 概率沿用原始 FFN residual branch 的配置。
小初始化的 \(A_o\) 使新增分支在训练初期接近恒等残差更新,同时允许模型逐步学习轨迹交互。
## 10. 核心设计思想
**Attention**:负责从历史疾病序列中选择并整合相关信息。
**Group Feature Alignment**:负责学习不同 latent trajectory groups 的内部特征对齐。
**Cross-Group Mixer**:负责不同潜在疾病轨迹之间的非线性门控交互。
整个模块保持:
- 无时间递归;
- 序列维度完全并行;
- 参数量远低于原始 FFN
- 保留 Transformer 的因果历史建模能力;
- 不把 residual groups 误解释为原始 Attention heads。
## 11. 首版固定配置
```yaml
model_architecture: traj_mixer_v2
d_model: 120
n_head: 10 # 同时决定 residual group 数量
d_group: 12
hidden_group_rule: 4 * n_head # 不单独配置
attention: unchanged
attention_output_projection: unchanged
mixer_norm: standard_layer_norm
group_alignment: per_group_12x12
group_alignment_bias: false
group_alignment_init: identity
mixer_bias: false
gate_value_init: xavier_uniform
output_init_std: 0.001
group_wise_layer_norm: false
```
训练时必须将 `model_architecture: traj_mixer_v2``model_parameter_count``trainable_parameter_count` 写入 `train_config.json`,并在训练日志中显式打印总参数量与可训练参数量。本分支的评估和导出入口只接受带有该架构标识、且 checkpoint 中包含 TrajMixer 参数张量的模型;其他版本或分支生成的模型应直接拒绝加载。
必须满足:
\[
d=n_{\mathrm{group}}d_{\mathrm{group}}.
\]
后续实现、单元测试、参数量核验和主实验均以以上配置为默认基线。

View File

@@ -4,6 +4,12 @@ import torch
import torch.nn as nn
import torch.nn.functional as F
from model_architectures import (
TRAJ_MIXER_ARCHITECTURE,
TRANSFORMER_FFN_ARCHITECTURE,
resolve_model_architecture,
)
class TimeRoPE(nn.Module):
def __init__(self, dim: int, base: float = 10000.0):
@@ -111,7 +117,10 @@ class TemporalAttention(nn.Module):
# 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))
# Keep the initial RBF attention bias exactly zero through the
# zero-initialized projection, while leaving that projection with a
# live gradient from the first optimization step.
self.time_bias_scale = nn.Parameter(torch.tensor(1.0))
self.resid_drop = nn.Dropout(dropout)
self.reset_parameters()
@@ -176,8 +185,42 @@ class TemporalAttention(nn.Module):
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 TrajMixer(nn.Module):
"""Lightweight gated interaction across latent residual-space groups.
"""PreNorm gated mixing within and across latent trajectory groups.
The groups are contiguous partitions of the post-``W_O`` residual
representation. They are deliberately not treated as attention heads.
@@ -201,19 +244,26 @@ class TrajMixer(nn.Module):
f"n_embd must be divisible by n_head, got {n_embd} and {n_head}"
)
self.n_embd = n_embd
# The residual-group count is tied to n_head, but the resulting groups
# are still residual-space partitions rather than attention heads.
self.n_group = n_head
self.d_group = n_embd // n_head
self.intra_hidden = 4 * self.d_group
self.hidden_group = 4 * n_head
# Per-group feature alignment: [group, input feature, output feature].
self.group_align = nn.Parameter(
torch.empty(self.n_group, self.d_group, self.d_group)
self.norm = nn.LayerNorm(self.n_embd)
self.intra_gate_proj = nn.Parameter(
torch.empty(self.n_group, self.d_group, self.intra_hidden)
)
self.intra_value_proj = nn.Parameter(
torch.empty(self.n_group, self.d_group, self.intra_hidden)
)
self.intra_output_proj = nn.Parameter(
torch.empty(self.n_group, self.intra_hidden, self.d_group)
)
self.intra_gate_logits = nn.Parameter(
torch.empty(self.n_group, self.d_group)
)
# Per-feature cross-group projections. The feature index is kept
# independent, exactly as specified by the TrajMixer baseline.
self.gate_proj = nn.Parameter(
torch.empty(self.d_group, self.n_group, self.hidden_group)
)
@@ -227,57 +277,116 @@ class TrajMixer(nn.Module):
self.reset_parameters()
def reset_parameters(self) -> None:
with torch.no_grad():
identity = torch.eye(
self.d_group,
dtype=self.group_align.dtype,
device=self.group_align.device,
)
self.group_align.copy_(identity.unsqueeze(0).expand_as(self.group_align))
for group_idx in range(self.n_group):
nn.init.xavier_uniform_(self.intra_gate_proj[group_idx])
nn.init.xavier_uniform_(self.intra_value_proj[group_idx])
nn.init.xavier_uniform_(self.intra_output_proj[group_idx])
nn.init.constant_(
self.intra_gate_logits,
math.log(0.1 / 0.9),
)
# Initialise each feature-specific matrix independently so Xavier's
# fan-in/fan-out calculation sees a two-dimensional matrix.
for feature_idx in range(self.d_group):
nn.init.xavier_uniform_(self.gate_proj[feature_idx])
nn.init.xavier_uniform_(self.value_proj[feature_idx])
nn.init.normal_(self.output_proj, mean=0.0, std=1e-3)
def _intra_mix(self, grouped: torch.Tensor) -> torch.Tensor:
"""Mix features independently inside each residual-space group."""
intra_gate = torch.einsum(
"blgd,gdh->blgh", grouped, self.intra_gate_proj
)
intra_value = torch.einsum(
"blgd,gdh->blgh", grouped, self.intra_value_proj
)
intra_hidden = F.silu(intra_gate) * intra_value
return torch.einsum(
"blgh,ghd->blgd", intra_hidden, self.intra_output_proj
)
def _cross_mix(self, grouped: torch.Tensor) -> torch.Tensor:
"""Mix groups independently for each within-group coordinate."""
gate = torch.einsum(
"blgr,rgh->blhr", grouped, self.gate_proj
)
value = torch.einsum(
"blgr,rgh->blhr", grouped, self.value_proj
)
hidden = F.silu(gate) * value
return torch.einsum(
"blhr,rhg->blgr", hidden, self.output_proj
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Map ``(B, L, n_embd)`` to an equally shaped residual update."""
"""Apply one full-width PreNorm and one outer residual update."""
if x.ndim != 3:
raise ValueError(f"TrajMixer expects a 3D tensor, got shape {tuple(x.shape)}")
raise ValueError(
f"TrajMixer expects a 3D tensor, got shape {tuple(x.shape)}"
)
if x.size(-1) != self.n_embd:
raise ValueError(
f"Expected hidden size {self.n_embd}, got {x.size(-1)}"
)
batch_size, seq_len, _ = x.shape
grouped = x.reshape(
grouped = self.norm(x).reshape(
batch_size, seq_len, self.n_group, self.d_group
)
aligned = torch.einsum(
"blgd,gde->blge", grouped, self.group_align
)
gate = torch.einsum(
"blgr,rgh->blhr", aligned, self.gate_proj
intra_output = self._intra_mix(grouped)
intra_gate = torch.sigmoid(self.intra_gate_logits).view(
1, 1, self.n_group, self.d_group
)
value = torch.einsum(
"blgr,rgh->blhr", aligned, self.value_proj
mixed_input = grouped + intra_gate * intra_output
update = self._cross_mix(mixed_input).reshape(
batch_size, seq_len, self.n_embd
)
hidden = F.silu(gate) * value
mixed = torch.einsum(
"blhr,rhg->blgr", hidden, self.output_proj
)
return self.drop(mixed.reshape(batch_size, seq_len, self.n_embd))
return x + self.drop(update)
class GPTBlock(nn.Module):
class TransformerFFNBlock(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 TrajMixerBlock(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,
@@ -299,7 +408,6 @@ class GPTBlock(nn.Module):
dropout=mlp_dropout,
)
self.ln1 = nn.LayerNorm(n_embd)
self.ln2 = nn.LayerNorm(n_embd)
def forward(
self,
@@ -309,8 +417,38 @@ class GPTBlock(nn.Module):
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
return self.mlp(x)
def build_backbone_block(
model_architecture: str,
*,
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,
) -> nn.Module:
"""Build one history block for a supported model architecture."""
architecture = resolve_model_architecture(model_architecture)
block_class: type[nn.Module]
if architecture == TRANSFORMER_FFN_ARCHITECTURE:
block_class = TransformerFFNBlock
elif architecture == TRAJ_MIXER_ARCHITECTURE:
block_class = TrajMixerBlock
else: # pragma: no cover - guarded by resolve_model_architecture.
raise ValueError(f"Unsupported model architecture: {architecture!r}")
return block_class(
n_embd=n_embd,
n_head=n_head,
attn_dropout=attn_dropout,
mlp_dropout=mlp_dropout,
use_time_rope=use_time_rope,
use_rbf_bias=use_rbf_bias,
n_rbf_bases=n_rbf_bases,
)
class TokenAutoDiscretization(nn.Module):

183
delphi2m_auc_report.py Normal file
View File

@@ -0,0 +1,183 @@
"""Build Delphi2M-style sex-specific AUC reports.
The Delphi2M evaluation code uses 0.1 years for the no-gap evaluation. The
published report displays that point as 0 months, while retaining the actual
0.1-year evaluation period in this project's report output.
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, Optional
import numpy as np
import pandas as pd
DEFAULT_DELPHI2M_PERIODS_YEARS = (0.1, 1.0, 5.0, 10.0)
_CHAPTER_SHORT_NAMES = {
"I": "I. Infectious Diseases",
"II": "II. Neoplasms",
"III": "III. Blood & Immune Disorders",
"IV": "IV. Metabolic Diseases",
"V": "V. Mental Disorders",
"VI": "VI. Nervous System Diseases",
"VII": "VII. Eye Diseases",
"VIII": "VIII. Ear Diseases",
"IX": "IX. Circulatory Diseases",
"X": "X. Respiratory Diseases",
"XI": "XI. Digestive Diseases",
"XII": "XII. Skin Diseases",
"XIII": "XIII. Musculoskeletal Diseases",
"XIV": "XIV. Genitourinary Diseases",
"XV": "XV. Pregnancy & Childbirth",
"XVI": "XVI. Perinatal Conditions",
"XVII": "XVII. Congenital Abnormalities",
"XVIII": "XVIII. Symptoms & Signs",
"XIX": "XIX. Injury & Poisoning",
"XX": "XX. External Causes",
"XXI": "XXI. Health Services",
"XXII": "XXII. Special Purposes",
"Death": "Death",
"Unmapped": "Unmapped",
}
def _is_no_gap(period_years: float) -> bool:
return bool(np.isclose(float(period_years), 0.1, rtol=0.0, atol=1e-8))
def _canonical_period_years(period_years: float) -> float:
value = float(period_years)
for canonical in DEFAULT_DELPHI2M_PERIODS_YEARS:
if np.isclose(value, canonical, rtol=0.0, atol=1e-6):
return float(canonical)
return value
def _gap_months(period_years: float) -> int:
if _is_no_gap(period_years):
return 0
return int(round(float(period_years) * 12.0))
def _gap_label(period_years: float) -> str:
if _is_no_gap(period_years):
return "No gap"
value = float(period_years)
value_text = f"{value:g}"
unit = "year" if np.isclose(value, 1.0) else "years"
return f"{value_text} {unit}"
def _load_chapter_by_code(
chapter_mapping_path: Optional[str | Path] = None,
) -> Dict[str, str]:
if chapter_mapping_path is None:
chapter_mapping_path = Path(__file__).with_name(
"icd10_chapter_organ_mapping.csv"
)
path = Path(chapter_mapping_path)
if not path.exists():
return {}
mapping = pd.read_csv(
path,
usecols=["code", "icd10_chapter"],
dtype={"code": str, "icd10_chapter": str},
)
mapping["code"] = mapping["code"].str.strip()
mapping["chapter"] = (
mapping["icd10_chapter"]
.str.strip()
.map(_CHAPTER_SHORT_NAMES)
.fillna("Unmapped")
)
return dict(zip(mapping["code"], mapping["chapter"]))
def build_delphi2m_auc_report(
df_unpooled: pd.DataFrame,
*,
period_col: str,
chapter_mapping_path: Optional[str | Path] = None,
) -> pd.DataFrame:
"""Aggregate age strata by sex and return a Delphi2M-style AUC report.
Required input columns are ``token``, ``label_code``, ``sex``,
``auc_delong``, and the supplied ``period_col`` (``offset`` or
``horizon``). The output begins with the five columns used by Delphi2M
Fig. 2e and then records the actual evaluation period and ICD-10 code.
"""
required = {"token", "label_code", "sex", "auc_delong", period_col}
missing = sorted(required - set(df_unpooled.columns))
if missing:
raise ValueError(
"Cannot build Delphi2M AUC report; missing columns: "
+ ", ".join(missing)
)
source = df_unpooled.loc[
:,
["token", "label_code", "sex", "auc_delong", period_col],
].copy()
source["sex"] = source["sex"].astype(str).str.strip().str.lower()
source = source[source["sex"].isin(["female", "male"])]
source["auc_delong"] = pd.to_numeric(
source["auc_delong"], errors="coerce"
)
source[period_col] = pd.to_numeric(source[period_col], errors="coerce")
source = source.dropna(subset=[period_col, "auc_delong"])
source[period_col] = source[period_col].map(_canonical_period_years)
if source.empty:
raise ValueError("Cannot build Delphi2M AUC report from empty AUC data.")
grouped = (
source.groupby(
["token", "label_code", period_col, "sex"],
dropna=False,
as_index=False,
)
.agg(auc=("auc_delong", "mean"))
)
report = (
grouped.pivot(
index=["token", "label_code", period_col],
columns="sex",
values="auc",
)
.reset_index()
.rename_axis(columns=None)
.rename(columns={"female": "Female", "male": "Male"})
)
for col in ["Female", "Male"]:
if col not in report.columns:
report[col] = np.nan
chapter_by_code = _load_chapter_by_code(chapter_mapping_path)
report["chapter"] = (
report["label_code"].astype(str).map(chapter_by_code).fillna("Unmapped")
)
report["Gap, months"] = report[period_col].map(_gap_months).astype("Int64")
report["Gap label"] = report[period_col].map(_gap_label)
report["icd10"] = pd.to_numeric(report["token"], errors="coerce").astype(
"Int64"
)
report = report.sort_values(
["icd10", period_col], kind="stable", ignore_index=True
)
return report.loc[
:,
[
"Gap, months",
"chapter",
"icd10",
"Female",
"Male",
period_col,
"Gap label",
"label_code",
],
]

View File

@@ -7,7 +7,8 @@ This script follows the logic of the Delphi evaluation script supplied by the us
at least `offset` years before the target time;
3. run model inference by disease chunks to avoid materializing all logits;
4. compute AUC separately by sex and age bracket;
5. aggregate age brackets with DeLong variance.
5. average age-bracket AUCs within each sex and write a Delphi2M-style
Female/Male report.
Efficiency notes:
- transformer/readout inference is executed once and cached;
@@ -39,12 +40,13 @@ from torch.utils.data import DataLoader, Subset
from tqdm.auto import tqdm
from dataset import HealthDataset
from eval_data import load_sequence_eval_dataset, sequence_eval_collate_fn
from models import (
DeepHealth,
validate_traj_mixer_config,
validate_traj_mixer_state_dict,
from delphi2m_auc_report import (
DEFAULT_DELPHI2M_PERIODS_YEARS,
build_delphi2m_auc_report,
)
from eval_data import load_sequence_eval_dataset, sequence_eval_collate_fn
from model_architectures import resolve_model_architecture
from models import DeepHealth
from readouts import build_readout
from targets import PAD_IDX, CHECKUP_IDX, NO_EVENT_IDX
@@ -312,20 +314,24 @@ def split_indices(n: int, train_ratio: float, val_ratio: float, test_ratio: floa
return idx[:n_train], idx[n_train:n_train + n_val], idx[n_train + n_val:]
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
validate_traj_mixer_config(cfg)
def build_model_from_dataset(
args: argparse.Namespace,
cfg: Dict[str, Any],
dataset: HealthDataset,
state_dict: Optional[Dict[str, Any]] = None,
) -> DeepHealth:
model_target_mode = str(cfg_get(
args, cfg, "model_target_mode", "next_token")).lower()
if model_target_mode not in {"next_token", "all_future"}:
raise ValueError(
f"model_target_mode must be next_token or all_future, got {model_target_mode!r}"
)
model_architecture = resolve_model_architecture(cfg, state_dict)
return DeepHealth(
vocab_size=dataset.vocab_size,
n_embd=int(cfg_get(args, cfg, "n_embd", 120)),
n_head=int(cfg_get(args, cfg, "n_head", 10)),
n_hist_layer=int(cfg_get(args, cfg, "n_hist_layer", 12)),
n_tab_layer=int(cfg_get(args, cfg, "n_tab_layer", 4)),
n_layer=int(cfg["n_layer"]),
n_types=dataset.n_types,
n_cont_types=dataset.n_cont_types,
n_categories=dataset.n_categories,
@@ -336,6 +342,7 @@ def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], data
time_mode=str(cfg_get(args, cfg, "time_mode", "relative")),
dist_mode=str(cfg_get(args, cfg, "dist_mode", "exponential")),
dropout=float(cfg_get(args, cfg, "dropout", 0.0)),
model_architecture=model_architecture,
)
@@ -383,7 +390,7 @@ def resolve_dist_mode_for_checkpoint(cfg_dist_mode: str, state_dict: Dict[str, A
def load_model_state(
model: torch.nn.Module,
model: DeepHealth,
checkpoint_path: str,
device: torch.device,
state_dict: Optional[Dict[str, Any]] = None,
@@ -391,7 +398,7 @@ def load_model_state(
state = state_dict if state_dict is not None else load_checkpoint_state_dict(
checkpoint_path, map_location=device)
validate_traj_mixer_state_dict(state)
resolve_model_architecture(model.model_architecture, state)
model.load_state_dict(state, strict=True)
@@ -1164,30 +1171,23 @@ def evaluate_auc_pipeline(
df_auc_unpooled["label_code"] = df_auc_unpooled["token"].map(
dataset.label_id_to_code)
print("Using DeLong method to calculate AUC confidence intervals.")
grouped = df_auc_unpooled.groupby(
["token", "label_code", "offset"], dropna=False, as_index=False)
df_auc = grouped.agg(
auc=("auc_delong", "mean"),
n_strata=("auc_delong", "size"),
n_diseased=("n_diseased", "sum"),
n_healthy=("n_healthy", "sum"),
auc_variance_sum=("auc_variance_delong", "sum"),
print(
"Building Delphi2M-style report: mean AUC across age strata, "
"reported separately for Female and Male."
)
df_auc["auc_variance_delong"] = (
df_auc["auc_variance_sum"]
/ (df_auc["n_strata"].clip(lower=1).astype(np.float64) ** 2)
df_report = build_delphi2m_auc_report(
df_auc_unpooled,
period_col="offset",
)
df_auc = df_auc.drop(columns=["auc_variance_sum"])
if output_path is not None:
out_dir = Path(output_path)
out_dir.mkdir(parents=True, exist_ok=True)
df_auc.to_csv(out_dir / "df_both.csv", index=False)
df_auc_unpooled.to_csv(
out_dir / "df_auc_unpooled.csv", index=False)
report_path = out_dir / "df_auc_delphi2m_report.csv"
df_report.to_csv(report_path, index=False)
print(f"Saved Delphi2M-style AUC report: {report_path}")
return df_auc_unpooled, df_auc
return df_auc_unpooled, df_report
# ---------------------------------------------------------------------------
@@ -1237,8 +1237,18 @@ def make_auc_offsets(args: argparse.Namespace, cfg: Dict[str, Any]) -> List[floa
if explicit_offsets is not None:
base_offsets = explicit_offsets
else:
next_token_offset = float(cfg_get(args, cfg, "offset", 0.1))
base_offsets = [next_token_offset, 1.0, 5.0, 10.0]
next_token_offset = float(
cfg_get(
args,
cfg,
"offset",
DEFAULT_DELPHI2M_PERIODS_YEARS[0],
)
)
base_offsets = [
next_token_offset,
*DEFAULT_DELPHI2M_PERIODS_YEARS[1:],
]
offsets: List[float] = []
seen = set()
@@ -1286,9 +1296,9 @@ def main() -> None:
parser.add_argument("--filter_min_total", type=int, default=None,
help="Minimum metadata count for disease selection; default 0.")
parser.add_argument("--offset", type=float, default=None,
help="Next-token prediction offset in years; preserved and evaluated alongside 1, 5, and 10 years by default.")
help="Next-token prediction offset in years; 0.1 is Delphi2M no gap and is evaluated alongside 1, 5, and 10 years by default.")
parser.add_argument("--offsets", type=str, default=None,
help="Comma-separated prediction offsets in years. Overrides the default set of offset,1,5,10.")
help="Comma-separated prediction offsets in years. Overrides the default set of 0.1,1,5,10.")
parser.add_argument("--age_start", type=float, default=None)
parser.add_argument("--age_stop", type=float, default=None)
parser.add_argument("--age_step", type=float, default=None)
@@ -1369,14 +1379,19 @@ def main() -> None:
cfg = dict(cfg)
cfg["dist_mode"] = dist_mode
cfg["model_target_mode"] = model_target_mode
model_architecture = resolve_model_architecture(cfg, state_dict)
cfg["model_architecture"] = model_architecture
print(f"Resolved dist_mode for evaluation: {dist_mode}")
print(f"Resolved model architecture: {model_architecture}")
print(f"Model target mode for AUC: {model_target_mode}")
print(
"AUC score semantics: evaluate_auc.py uses disease-specific eta/logit scores; "
"dist_mode affects model loading but is not converted to horizon-specific risk probability."
)
model = build_model_from_dataset(args, cfg, dataset).to(device)
model = build_model_from_dataset(
args, cfg, dataset, state_dict=state_dict
).to(device)
load_model_state(model, str(model_ckpt_path),
device, state_dict=state_dict)
model.eval()

View File

@@ -3,6 +3,9 @@
This script supports DeepHealth fixed-horizon risk scores for exponential,
Weibull, and mixed all-future distributions.
The default horizons are 0.1, 1, 5, and 10 years. As in Delphi2M, 0.1 years
is reported as the no-gap evaluation.
Landmark querying depends on the model target mode saved in train_config.json:
- next_token: insert a <NO_EVENT> token at landmark age and read it out;
- all_future: pass landmark age directly as t_query.
@@ -28,12 +31,13 @@ from torch.utils.data import DataLoader, Dataset
from tqdm.auto import tqdm
from dataset import HealthDataset
from eval_data import load_sequence_eval_dataset
from models import (
DeepHealth,
validate_traj_mixer_config,
validate_traj_mixer_state_dict,
from delphi2m_auc_report import (
DEFAULT_DELPHI2M_PERIODS_YEARS,
build_delphi2m_auc_report,
)
from eval_data import load_sequence_eval_dataset
from model_architectures import resolve_model_architecture
from models import DeepHealth
from readouts import build_readout
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX
@@ -181,20 +185,24 @@ def resolve_dist_mode_for_checkpoint(cfg_dist_mode: str, state_dict: Dict[str, A
return mode if mode in {"exponential", "weibull", "mixed"} else "exponential"
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
validate_traj_mixer_config(cfg)
def build_model_from_dataset(
args: argparse.Namespace,
cfg: Dict[str, Any],
dataset: HealthDataset,
state_dict: Optional[Dict[str, Any]] = None,
) -> DeepHealth:
model_target_mode = str(cfg_get(
args, cfg, "model_target_mode", "next_token")).lower()
if model_target_mode not in {"next_token", "all_future"}:
raise ValueError(
f"model_target_mode must be next_token or all_future, got {model_target_mode!r}"
)
model_architecture = resolve_model_architecture(cfg, state_dict)
return DeepHealth(
vocab_size=dataset.vocab_size,
n_embd=int(cfg_get(args, cfg, "n_embd", 120)),
n_head=int(cfg_get(args, cfg, "n_head", 10)),
n_hist_layer=int(cfg_get(args, cfg, "n_hist_layer", 12)),
n_tab_layer=int(cfg_get(args, cfg, "n_tab_layer", 4)),
n_layer=int(cfg["n_layer"]),
n_types=dataset.n_types,
n_cont_types=dataset.n_cont_types,
n_categories=dataset.n_categories,
@@ -205,11 +213,12 @@ def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], data
time_mode=str(cfg_get(args, cfg, "time_mode", "relative")),
dist_mode=str(cfg_get(args, cfg, "dist_mode", "exponential")),
dropout=float(cfg_get(args, cfg, "dropout", 0.0)),
model_architecture=model_architecture,
)
def load_model_state(model: torch.nn.Module, state_dict: Dict[str, Any]) -> None:
validate_traj_mixer_state_dict(state_dict)
def load_model_state(model: DeepHealth, state_dict: Dict[str, Any]) -> None:
resolve_model_architecture(model.model_architecture, state_dict)
model.load_state_dict(state_dict, strict=True)
@@ -330,44 +339,6 @@ def _first_existing_column(df: pd.DataFrame, candidates: Sequence[str]) -> Optio
return None
def build_metadata_for_merge(dataset: HealthDataset, labels_meta: Optional[pd.DataFrame]) -> pd.DataFrame:
base_rows = []
for token, code in dataset.label_id_to_code.items():
token = int(token)
code_text = str(code)
if token in SPECIAL_TOKENS or code_text.startswith("<"):
continue
base_rows.append({"token": token, "label_code": code_text})
base = pd.DataFrame(base_rows)
if labels_meta is None or labels_meta.empty:
return base
meta = labels_meta.copy()
code_col = _first_existing_column(
meta, ["Name", "code", "ICD10", "icd10", "label", "token", "disease_code"])
if code_col is not None:
meta["_label_code"] = meta[code_col].astype(
str).map(lambda s: s.split()[0].strip())
merged = base.merge(meta, left_on="label_code",
right_on="_label_code", how="left")
return merged.drop(columns=["_label_code"], errors="ignore")
if "index" in meta.columns:
idx = pd.to_numeric(meta["index"], errors="coerce")
has_no_event = (
NO_EVENT_IDX in dataset.label_id_to_code
and dataset.label_id_to_code.get(NO_EVENT_IDX) == "<NO_EVENT>"
)
if has_no_event:
idx = idx.where(idx < NO_EVENT_IDX, idx + 1)
meta["_index_int"] = idx.astype("Int64")
merged = base.merge(meta, left_on="token",
right_on="_index_int", how="left")
return merged.drop(columns=["_index_int"], errors="ignore")
return base
def _metadata_count_map(dataset: HealthDataset, labels_meta: Optional[pd.DataFrame]) -> Dict[int, float]:
if labels_meta is None or labels_meta.empty or "count" not in labels_meta.columns:
return {}
@@ -1107,7 +1078,6 @@ def evaluate_landmark_auc(
loader: DataLoader,
landmark_dataset: LandmarkDataset,
output_path: Path,
labels_meta: Optional[pd.DataFrame],
disease_ids: Sequence[int],
disease_chunk_size: int,
score_mode: str,
@@ -1124,7 +1094,6 @@ def evaluate_landmark_auc(
use_amp: bool,
hidden_cache_dtype: str,
logit_batch_size: int,
meta_info: Dict[str, Any],
) -> Tuple[pd.DataFrame, pd.DataFrame]:
model.eval().to(device)
@@ -1241,54 +1210,21 @@ def evaluate_landmark_auc(
df_unpooled["label_code"] = df_unpooled["token"].map(
landmark_dataset.dataset.label_id_to_code)
for k, v in meta_info.items():
df_unpooled[k] = v
meta_table = build_metadata_for_merge(landmark_dataset.dataset, labels_meta)
df_unpooled = df_unpooled.merge(
meta_table, on=["token", "label_code"], how="left")
grouped = df_unpooled.groupby(
["token", "label_code", "horizon"], dropna=False, as_index=False)
df_merged = grouped.agg(
auc=("auc_delong", "mean"),
n_strata=("auc_delong", "size"),
n_diseased=("n_diseased", "sum"),
n_healthy=("n_healthy", "sum"),
auc_variance_sum=("auc_variance_delong", "sum"),
print(
"Building Delphi2M-style report: mean AUC across landmark-age "
"strata, reported separately for Female and Male."
)
df_merged["auc_variance_delong"] = (
df_merged["auc_variance_sum"]
/ (df_merged["n_strata"].clip(lower=1).astype(np.float64) ** 2)
df_report = build_delphi2m_auc_report(
df_unpooled,
period_col="horizon",
)
df_merged = df_merged.drop(columns=["auc_variance_sum"])
keep_meta = [
c for c in [
"model_ckpt_path",
"config_path",
"target_mode",
"model_target_mode",
"dist_mode",
"time_mode",
"attn_mask_mode",
"readout_name",
"landmark_query_mode",
"landmark_token_mode",
"score_mode",
"eval_split",
]
if c in df_unpooled.columns
]
for col in keep_meta:
df_merged[col] = meta_info[col]
output_path.mkdir(parents=True, exist_ok=True)
df_unpooled.to_csv(
output_path / "df_auc_landmark_unpooled.csv", index=False)
df_merged.to_csv(output_path / "df_auc_landmark.csv", index=False)
report_path = output_path / "df_auc_landmark_delphi2m_report.csv"
df_report.to_csv(report_path, index=False)
print(f"Saved Delphi2M-style landmark AUC report: {report_path}")
return df_unpooled, df_merged
return df_unpooled, df_report
def main() -> None:
@@ -1314,7 +1250,12 @@ def main() -> None:
parser.add_argument("--landmark_start", type=float, default=None)
parser.add_argument("--landmark_stop", type=float, default=None)
parser.add_argument("--landmark_step", type=float, default=None)
parser.add_argument("--horizons", type=str, default=None)
parser.add_argument(
"--horizons",
type=str,
default=None,
help="Comma-separated horizons in years; defaults to 0.1,1,5,10, where 0.1 is Delphi2M no gap.",
)
parser.add_argument("--min_cases", type=int, default=None)
parser.add_argument("--min_history_events", type=int, default=None)
@@ -1434,8 +1375,9 @@ def main() -> None:
"Landmark ages are empty. Check landmark_start/landmark_stop/landmark_step.")
horizons = np.asarray(
parse_float_list(cfg_get(args, cfg, "horizons", "1,5,10")) or [
1.0, 5.0, 10.0],
parse_float_list(
cfg_get(args, cfg, "horizons", "0.1,1,5,10")
) or list(DEFAULT_DELPHI2M_PERIODS_YEARS),
dtype=np.float32,
)
if horizons.size == 0:
@@ -1458,12 +1400,17 @@ def main() -> None:
cfg_model = dict(cfg)
cfg_model["dist_mode"] = dist_mode
model_architecture = resolve_model_architecture(cfg_model, state_dict)
cfg_model["model_architecture"] = model_architecture
print(f"Resolved model architecture: {model_architecture}")
device = resolve_eval_device(args.device)
if device.type == "cuda":
torch.backends.cudnn.benchmark = True
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
model = build_model_from_dataset(
args, cfg_model, dataset, state_dict=state_dict
).to(device)
if (
model_target_mode == "next_token"
@@ -1526,8 +1473,6 @@ def main() -> None:
if model_target_mode == "next_token"
else "direct_t_query"
)
score_mode_out = f"{landmark_query_mode}_{score_mode}"
num_workers_auc = int(
cfg_get(args, cfg, "num_workers_auc", max(1, (os.cpu_count() or 2) - 1)))
auc_task_chunk_size = int(cfg_get(args, cfg, "auc_task_chunk_size", 0))
@@ -1559,27 +1504,11 @@ def main() -> None:
print(f"AUC workers: {num_workers_auc}")
print(f"Output path: {output_path}")
meta_info = {
"score_mode": score_mode_out,
"eval_split": eval_split,
"model_ckpt_path": str(model_ckpt_path),
"config_path": str(config_path),
"target_mode": str(target_mode),
"model_target_mode": str(model_target_mode),
"dist_mode": str(dist_mode),
"time_mode": str(time_mode),
"attn_mask_mode": str(attn_mask_mode),
"readout_name": str(readout_name),
"landmark_query_mode": landmark_query_mode,
"landmark_token_mode": "no_event" if model_target_mode == "next_token" else "none",
}
evaluate_landmark_auc(
model=model,
loader=loader,
landmark_dataset=landmark_dataset,
output_path=output_path,
labels_meta=labels_meta,
disease_ids=disease_ids,
disease_chunk_size=disease_chunk_size,
score_mode=score_mode,
@@ -1596,7 +1525,6 @@ def main() -> None:
use_amp=use_amp,
hidden_cache_dtype=hidden_cache_dtype,
logit_batch_size=logit_batch_size,
meta_info=meta_info,
)

View File

@@ -649,7 +649,9 @@ def main() -> None:
cfg_model = dict(cfg)
cfg_model["dist_mode"] = dist_mode
device = resolve_eval_device(args.device)
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
model = build_model_from_dataset(
args, cfg_model, dataset, state_dict=state_dict
).to(device)
load_model_state(model, state_dict)
model.eval()

View File

@@ -758,7 +758,9 @@ def main() -> None:
cfg_model = dict(cfg)
cfg_model["dist_mode"] = dist_mode
device = resolve_eval_device(args.device)
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
model = build_model_from_dataset(
args, cfg_model, dataset, state_dict=state_dict
).to(device)
load_model_state(model, state_dict)
model.eval()

View File

@@ -553,7 +553,9 @@ def main() -> None:
device = resolve_eval_device(args.device)
selected_token_mask = np.zeros(int(dataset.vocab_size), dtype=bool)
selected_token_mask[np.asarray(scanned_disease_tokens, dtype=np.int64)] = True
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
model = build_model_from_dataset(
args, cfg_model, dataset, state_dict=state_dict
).to(device)
load_model_state(model, state_dict)
model.eval()

View File

@@ -180,7 +180,9 @@ def main() -> None:
cfg_model = dict(cfg)
cfg_model["dist_mode"] = dist_mode
device = resolve_eval_device(args.device)
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
model = build_model_from_dataset(
args, cfg_model, dataset, state_dict=state_dict
).to(device)
load_model_state(model, state_dict)
model.eval()

View File

@@ -381,7 +381,9 @@ def main() -> None:
cfg_model = dict(cfg)
cfg_model["dist_mode"] = dist_mode
device = resolve_eval_device(args.device)
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
model = build_model_from_dataset(
args, cfg_model, dataset, state_dict=state_dict
).to(device)
load_model_state(model, state_dict)
model.eval()

131
model_architectures.py Normal file
View File

@@ -0,0 +1,131 @@
"""Model-architecture identifiers and checkpoint validation helpers."""
from __future__ import annotations
import re
from collections.abc import Mapping
TRANSFORMER_FFN_ARCHITECTURE = "transformer_ffn_v1"
TRAJ_MIXER_ARCHITECTURE = "traj_mixer_v5"
DEFAULT_MODEL_ARCHITECTURE = TRANSFORMER_FFN_ARCHITECTURE
SUPPORTED_MODEL_ARCHITECTURES = (
TRANSFORMER_FFN_ARCHITECTURE,
TRAJ_MIXER_ARCHITECTURE,
)
_FFN_STATE_KEY = re.compile(
r"(?:^|\.)blocks\.\d+\.mlp\.w[123]\.(?:weight|bias)$"
)
_TRAJ_MIXER_STATE_KEY = re.compile(
r"(?:^|\.)blocks\.\d+\.mlp\.(?:"
r"norm\.(?:weight|bias)|"
r"intra_gate_proj|"
r"intra_value_proj|"
r"intra_output_proj|"
r"intra_gate_logits|"
r"gate_proj|"
r"value_proj|"
r"output_proj"
r")$"
)
def _validate_model_architecture(model_architecture: object) -> str:
if not isinstance(model_architecture, str):
raise ValueError(
"model_architecture must be one of "
f"{SUPPORTED_MODEL_ARCHITECTURES}, got {model_architecture!r}"
)
if model_architecture not in SUPPORTED_MODEL_ARCHITECTURES:
raise ValueError(
f"Unsupported model_architecture={model_architecture!r}; "
f"expected one of {SUPPORTED_MODEL_ARCHITECTURES}."
)
return model_architecture
def detect_model_architecture_from_state_dict(
state_dict: Mapping[str, object],
) -> str:
"""Infer the architecture from block parameter names.
Detection deliberately accepts any ``blocks.<index>`` prefix rather than
assuming that block zero is present.
"""
if not isinstance(state_dict, Mapping):
raise TypeError(
"state_dict must be a mapping, got "
f"{type(state_dict).__name__}"
)
has_ffn = False
has_traj_mixer = False
for raw_key in state_dict:
key = str(raw_key)
has_ffn = has_ffn or _FFN_STATE_KEY.search(key) is not None
has_traj_mixer = (
has_traj_mixer
or _TRAJ_MIXER_STATE_KEY.search(key) is not None
)
if has_ffn and has_traj_mixer:
raise ValueError(
"Checkpoint contains both Transformer FFN and TrajMixer "
"block parameters; its model architecture is ambiguous."
)
if has_ffn:
return TRANSFORMER_FFN_ARCHITECTURE
if has_traj_mixer:
return TRAJ_MIXER_ARCHITECTURE
raise ValueError(
"Could not detect model architecture from checkpoint parameters. "
"Expected a blocks.<index>.mlp FFN or TrajMixer parameter."
)
def resolve_model_architecture(
config_or_marker: Mapping[str, object] | str | None = None,
state_dict: Mapping[str, object] | None = None,
) -> str:
"""Resolve and cross-check a configured and checkpoint architecture.
Every saved run must provide an explicit architecture marker. Checkpoint
parameter names are used only to verify that the marker describes the
weights being loaded.
"""
if isinstance(config_or_marker, Mapping):
configured = config_or_marker.get("model_architecture")
elif isinstance(config_or_marker, str) or config_or_marker is None:
configured = config_or_marker
else:
raise TypeError(
"config_or_marker must be a config mapping, string, or None, got "
f"{type(config_or_marker).__name__}"
)
resolved_config = (
_validate_model_architecture(configured)
if configured is not None
else None
)
detected = (
detect_model_architecture_from_state_dict(state_dict)
if state_dict is not None
else None
)
if resolved_config is None:
raise ValueError(
"model_architecture is required; expected one of "
f"{SUPPORTED_MODEL_ARCHITECTURES}."
)
if detected is not None and resolved_config != detected:
raise ValueError(
"Configured model architecture conflicts with checkpoint: "
f"config={resolved_config!r}, checkpoint={detected!r}."
)
return resolved_config

View File

@@ -1,4 +1,3 @@
from collections.abc import Mapping
from dataclasses import dataclass
import torch
@@ -7,41 +6,15 @@ import torch.nn.functional as F
from backbones import (
AgeSinusoidalEncoding,
GPTBlock,
GaussianRBFTimeBasis,
TimeRoPE,
TokenAutoDiscretization,
build_backbone_block,
)
from model_architectures import resolve_model_architecture
from targets import PAD_IDX
TRAJ_MIXER_ARCHITECTURE = "traj_mixer_v2"
def validate_traj_mixer_config(config: Mapping[str, object]) -> None:
actual = config.get("model_architecture")
if actual != TRAJ_MIXER_ARCHITECTURE:
raise ValueError(
"This branch only accepts models trained with the TrajMixer "
f"architecture marker {TRAJ_MIXER_ARCHITECTURE!r}; got {actual!r}."
)
def validate_traj_mixer_state_dict(state_dict: Mapping[str, object]) -> None:
required_keys = {
"blocks.0.mlp.group_align",
"blocks.0.mlp.gate_proj",
"blocks.0.mlp.value_proj",
"blocks.0.mlp.output_proj",
}
missing = sorted(required_keys.difference(state_dict))
if missing:
raise ValueError(
"Checkpoint is not a TrajMixer checkpoint; missing required "
f"parameters: {', '.join(missing)}"
)
@dataclass
class DeepHealthOutput:
hidden: torch.Tensor
@@ -175,8 +148,7 @@ class DeepHealth(nn.Module):
vocab_size: int,
n_embd: int,
n_head: int,
n_hist_layer: int,
n_tab_layer: int,
n_layer: int,
n_types: int,
n_cont_types: int,
n_categories: int,
@@ -188,6 +160,7 @@ class DeepHealth(nn.Module):
dist_mode: str = "exponential", # "exponential", "weibull" or "mixed"
extra_pool_reduce: str = "mean",
dropout: float = 0.0,
model_architecture: str | None = None,
):
super().__init__()
if target_mode not in ["next_token", "all_future"]:
@@ -201,6 +174,9 @@ class DeepHealth(nn.Module):
"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'")
if n_layer < 1:
raise ValueError(f"n_layer must be >= 1, got {n_layer}")
model_architecture = resolve_model_architecture(model_architecture)
self.token_embedding = nn.Embedding(vocab_size, n_embd, padding_idx=0)
self.gender_embedding = nn.Embedding(
2, n_embd) # Assuming binary gender
@@ -217,6 +193,8 @@ class DeepHealth(nn.Module):
self.time_mode = time_mode
self.dist_mode = dist_mode
self.extra_pool_reduce = extra_pool_reduce
self.model_architecture = model_architecture
self.n_layer = n_layer
self.n_embd = n_embd
self.vocab_size = vocab_size
nn.init.normal_(self.token_embedding.weight, mean=0.0, std=0.02)
@@ -236,26 +214,28 @@ class DeepHealth(nn.Module):
if time_mode == "absolute":
self.age_encoding = AgeSinusoidalEncoding(n_embd)
self.blocks = nn.ModuleList([
GPTBlock(
build_backbone_block(
model_architecture,
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)
) for _ in range(n_layer)
])
self.rope = None
self.rbf = None
elif time_mode == "relative":
self.age_encoding = None
self.blocks = nn.ModuleList([
GPTBlock(
build_backbone_block(
model_architecture,
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)
) for _ in range(n_layer)
])
self.rope = TimeRoPE(n_embd // n_head)
self.rbf = GaussianRBFTimeBasis(n_bases=16, max_time_diff=40.0)

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Run all non-wrapper evaluation scripts for every completed experiment under
# runs/. The script is written for Linux servers with bash 4.2.
# Run all non-wrapper evaluation scripts for every completed current-format
# experiment under runs/. The script is written for Linux servers with bash 4.2.
cd "$(dirname "${BASH_SOURCE[0]}")"
shopt -s globstar nullglob
PYTHON_BIN="${PYTHON_BIN:-python}"
DEVICE="${DEVICE:-cuda}"
@@ -81,6 +82,20 @@ run_dir_result_if_missing() {
run_command "$@"
}
run_file_result_if_missing() {
local label="$1"
local result_dir="$2"
local required="$3"
shift 3
if [[ -s "${result_dir}/${required}" ]]; then
echo " skip ${label}: found ${result_dir}/${required}"
return 0
fi
run_command "$@"
}
run_has_extra_info() {
"${PYTHON_BIN}" - "$1" <<'PY'
import json
@@ -115,8 +130,30 @@ raise SystemExit(0 if mode == "all_future" else 1)
PY
}
for run_path in runs/*; do
[[ -d "${run_path}" ]] || continue
run_has_current_model_config() {
"${PYTHON_BIN}" - "$1" <<'PY'
import json
import sys
from pathlib import Path
cfg_path = Path(sys.argv[1]) / "train_config.json"
try:
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
n_layer = int(cfg.get("n_layer", 0))
except Exception:
raise SystemExit(1)
supported = {"transformer_ffn_v1", "traj_mixer_v5"}
raise SystemExit(
0
if cfg.get("model_architecture") in supported and n_layer >= 1
else 1
)
PY
}
for config_path in runs/**/train_config.json; do
run_path="${config_path%/train_config.json}"
echo "==> ${run_path}"
if [[ ! -f "${run_path}/train_config.json" ]]; then
@@ -127,6 +164,10 @@ for run_path in runs/*; do
echo " skip run: missing best_model.pt"
continue
fi
if ! run_has_current_model_config "${run_path}"; then
echo " skip run: config lacks current model_architecture/n_layer fields"
continue
fi
common=()
while IFS= read -r arg; do common+=("${arg}"); done < <(common_args_with_device "${run_path}")
@@ -137,18 +178,16 @@ for run_path in runs/*; do
cpu_reduce_extra=()
while IFS= read -r arg; do cpu_reduce_extra+=("${arg}"); done < <(cpu_reduce_args)
run_dir_result_if_missing \
run_file_result_if_missing \
"evaluate_auc.py" \
"${run_path}" \
"df_both.csv" \
"df_auc_unpooled.csv" \
"df_auc_delphi2m_report.csv" \
"${PYTHON_BIN}" evaluate_auc.py "${common[@]}" "${auc_extra[@]}"
run_dir_result_if_missing \
run_file_result_if_missing \
"evaluate_auc_v2.py" \
"${run_path}" \
"df_auc_landmark.csv" \
"df_auc_landmark_unpooled.csv" \
"df_auc_landmark_delphi2m_report.csv" \
"${PYTHON_BIN}" evaluate_auc_v2.py "${common[@]}" "${auc_extra[@]}"
if ! run_is_all_future "${run_path}"; then

View File

@@ -10,7 +10,8 @@ set -euo pipefail
# all_future + relative time + mixed death/risk head
#
# This script only launches those missing training jobs. It intentionally does
# not call evaluate_*.py and does not add extra random seeds.
# not call evaluate_*.py and does not add extra random seeds. Set
# MODEL_ARCHITECTURE=traj_mixer_v5 to run the TrajMixer variant.
cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -18,6 +19,8 @@ PYTHON_BIN="${PYTHON_BIN:-python}"
DEVICE="${DEVICE:-cuda}"
NUM_WORKERS="${NUM_WORKERS:-4}"
PROGRESS_INTERVAL="${PROGRESS_INTERVAL:-20}"
MODEL_ARCHITECTURE="${MODEL_ARCHITECTURE:-transformer_ffn_v1}"
N_LAYER="${N_LAYER:-12}"
TIME_MODE="relative"
DIST_MODE="mixed"
@@ -36,8 +39,8 @@ COMMON_ARGS=(
--min_future_events 1
--n_embd 120
--n_head 10
--n_hist_layer 12
--n_tab_layer 4
--n_layer "${N_LAYER}"
--model_architecture "${MODEL_ARCHITECTURE}"
--n_bins 16
--extra_pool_reduce mean
--dropout 0.0
@@ -57,15 +60,23 @@ COMMON_ARGS=(
already_trained() {
local extra_file="$1"
"${PYTHON_BIN}" - "$TIME_MODE" "$DIST_MODE" "$extra_file" "$SEED" "$VALIDATION_QUERY_SEED" <<'PY'
"${PYTHON_BIN}" - "$TIME_MODE" "$DIST_MODE" "$extra_file" "$SEED" "$VALIDATION_QUERY_SEED" "$MODEL_ARCHITECTURE" "$N_LAYER" <<'PY'
import json
import sys
from pathlib import Path
time_mode, dist_mode, extra_file, seed, validation_query_seed = sys.argv[1:6]
(
time_mode,
dist_mode,
extra_file,
seed,
validation_query_seed,
model_architecture,
n_layer,
) = sys.argv[1:8]
extra_name = Path(extra_file).name
for config_path in Path("runs").glob("*/train_config.json"):
for config_path in Path("runs").rglob("train_config.json"):
try:
cfg = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
@@ -78,6 +89,8 @@ for config_path in Path("runs").glob("*/train_config.json"):
if (
cfg.get("model_target_mode") == "all_future"
and cfg.get("model_architecture") == model_architecture
and int(cfg.get("n_layer", -1)) == int(n_layer)
and cfg.get("time_mode") == time_mode
and cfg.get("dist_mode") == dist_mode
and Path(str(cfg.get("extra_info_types_file", ""))).name == extra_name
@@ -100,7 +113,7 @@ train_if_missing() {
return 2
fi
echo "==> Checking ${label}: ${TIME_MODE} ${DIST_MODE} all_future with ${extra_file}"
echo "==> Checking ${label}: ${MODEL_ARCHITECTURE} n_layer=${N_LAYER} ${TIME_MODE} ${DIST_MODE} all_future with ${extra_file}"
if existing_run="$(already_trained "$extra_file")"; then
echo " skip: already trained at ${existing_run}"
return 0

247
test_model_architectures.py Normal file
View File

@@ -0,0 +1,247 @@
import unittest
import torch
from backbones import (
SwiGLU,
TrajMixer,
TrajMixerBlock,
TransformerFFNBlock,
build_backbone_block,
)
from model_architectures import (
TRAJ_MIXER_ARCHITECTURE,
TRANSFORMER_FFN_ARCHITECTURE,
detect_model_architecture_from_state_dict,
resolve_model_architecture,
)
from models import DeepHealth
def _build_block(model_architecture: str):
return build_backbone_block(
model_architecture,
n_embd=12,
n_head=3,
use_time_rope=False,
use_rbf_bias=False,
mlp_dropout=0.0,
)
def _as_model_state_dict(block: torch.nn.Module) -> dict[str, torch.Tensor]:
return {
f"blocks.0.{name}": value.detach().clone()
for name, value in block.state_dict().items()
}
def _build_model(
model_architecture: str | None,
*,
n_layer: int = 1,
) -> DeepHealth:
return DeepHealth(
vocab_size=8,
n_embd=12,
n_head=3,
n_layer=n_layer,
n_types=2,
n_cont_types=0,
n_categories=2,
cont_type_ids=[],
time_mode="absolute",
model_architecture=model_architecture,
)
class ModelArchitectureFactoryTest(unittest.TestCase):
def test_factory_builds_both_architectures_with_expected_topology(self) -> None:
ffn_block = _build_block(TRANSFORMER_FFN_ARCHITECTURE)
self.assertIsInstance(ffn_block, TransformerFFNBlock)
self.assertIsInstance(ffn_block.mlp, SwiGLU)
self.assertTrue(hasattr(ffn_block, "ln1"))
self.assertTrue(hasattr(ffn_block, "ln2"))
traj_block = _build_block(TRAJ_MIXER_ARCHITECTURE)
self.assertIsInstance(traj_block, TrajMixerBlock)
self.assertIsInstance(traj_block.mlp, TrajMixer)
self.assertTrue(hasattr(traj_block, "ln1"))
self.assertFalse(hasattr(traj_block, "ln2"))
def test_both_architectures_forward_and_backward(self) -> None:
for architecture in (
TRANSFORMER_FFN_ARCHITECTURE,
TRAJ_MIXER_ARCHITECTURE,
):
with self.subTest(architecture=architecture):
torch.manual_seed(0)
block = _build_block(architecture)
x = torch.randn(2, 5, 12, requires_grad=True)
output = block(x)
self.assertEqual(output.shape, x.shape)
output.square().mean().backward()
self.assertIsNotNone(x.grad)
self.assertTrue(torch.isfinite(x.grad).all())
self.assertGreater(x.grad.abs().sum().item(), 0.0)
self.assertIsNotNone(block.attn.qkv.weight.grad)
self.assertGreater(
block.attn.qkv.weight.grad.abs().sum().item(),
0.0,
)
if architecture == TRANSFORMER_FFN_ARCHITECTURE:
branch_parameters = (
block.mlp.w1.weight,
block.mlp.w2.weight,
block.mlp.w3.weight,
)
else:
branch_parameters = (
block.mlp.intra_gate_proj,
block.mlp.intra_value_proj,
block.mlp.output_proj,
)
for parameter in branch_parameters:
self.assertIsNotNone(parameter.grad)
self.assertTrue(torch.isfinite(parameter.grad).all())
self.assertGreater(parameter.grad.abs().sum().item(), 0.0)
def test_unknown_architecture_is_rejected(self) -> None:
with self.assertRaises(ValueError):
_build_block("unknown_architecture")
with self.assertRaisesRegex(ValueError, "model_architecture is required"):
_build_model(None)
def test_deephealth_rejects_fewer_than_one_layer(self) -> None:
for n_layer in (0, -1):
with self.subTest(n_layer=n_layer):
with self.assertRaisesRegex(ValueError, "n_layer must be >= 1"):
_build_model(
TRANSFORMER_FFN_ARCHITECTURE,
n_layer=n_layer,
)
def test_deephealth_uses_factory_and_strictly_reloads_both_models(self) -> None:
for architecture, block_class in (
(TRANSFORMER_FFN_ARCHITECTURE, TransformerFFNBlock),
(TRAJ_MIXER_ARCHITECTURE, TrajMixerBlock),
):
with self.subTest(architecture=architecture):
model = _build_model(architecture)
self.assertEqual(model.model_architecture, architecture)
self.assertIsInstance(model.blocks[0], block_class)
self.assertEqual(
detect_model_architecture_from_state_dict(
model.state_dict()
),
architecture,
)
reloaded = _build_model(architecture)
incompatible = reloaded.load_state_dict(
model.state_dict(),
strict=True,
)
self.assertEqual(incompatible.missing_keys, [])
self.assertEqual(incompatible.unexpected_keys, [])
class ModelArchitectureResolutionTest(unittest.TestCase):
def setUp(self) -> None:
self.ffn_block = _build_block(TRANSFORMER_FFN_ARCHITECTURE)
self.traj_block = _build_block(TRAJ_MIXER_ARCHITECTURE)
self.ffn_state = _as_model_state_dict(self.ffn_block)
self.traj_state = _as_model_state_dict(self.traj_block)
def test_state_dict_detection_recognizes_both_architectures(self) -> None:
self.assertEqual(
detect_model_architecture_from_state_dict(self.ffn_state),
TRANSFORMER_FFN_ARCHITECTURE,
)
self.assertEqual(
detect_model_architecture_from_state_dict(self.traj_state),
TRAJ_MIXER_ARCHITECTURE,
)
def test_explicit_markers_resolve_when_checkpoint_matches(self) -> None:
for architecture, state_dict in (
(TRANSFORMER_FFN_ARCHITECTURE, self.ffn_state),
(TRAJ_MIXER_ARCHITECTURE, self.traj_state),
):
with self.subTest(architecture=architecture):
self.assertEqual(
resolve_model_architecture(
{"model_architecture": architecture},
state_dict,
),
architecture,
)
def test_architecture_marker_is_required_for_checkpoint_loading(self) -> None:
with self.assertRaisesRegex(ValueError, "model_architecture is required"):
resolve_model_architecture({}, self.ffn_state)
with self.assertRaisesRegex(ValueError, "model_architecture is required"):
resolve_model_architecture(None, self.traj_state)
def test_explicit_marker_conflicting_with_state_dict_is_rejected(self) -> None:
conflicts = (
(TRANSFORMER_FFN_ARCHITECTURE, self.traj_state),
(TRAJ_MIXER_ARCHITECTURE, self.ffn_state),
)
for architecture, state_dict in conflicts:
with self.subTest(architecture=architecture):
with self.assertRaises(ValueError):
resolve_model_architecture(
{"model_architecture": architecture},
state_dict,
)
def test_unknown_marker_and_ambiguous_state_dict_are_rejected(self) -> None:
with self.assertRaises(ValueError):
resolve_model_architecture(
{"model_architecture": "traj_mixer_v4"}
)
ambiguous_state = dict(self.ffn_state)
ambiguous_state.update(self.traj_state)
with self.assertRaises(ValueError):
detect_model_architecture_from_state_dict(ambiguous_state)
with self.assertRaises(ValueError):
detect_model_architecture_from_state_dict(
{"token_embedding.weight": torch.empty(2, 2)}
)
def test_ffn_block_schema_is_stable_and_strictly_loadable(self) -> None:
expected_keys = {
"attn.time_bias_scale",
"attn.qkv.weight",
"attn.out_proj.weight",
"attn.rbf_proj.weight",
"mlp.w1.weight",
"mlp.w1.bias",
"mlp.w2.weight",
"mlp.w2.bias",
"mlp.w3.weight",
"mlp.w3.bias",
"ln1.weight",
"ln1.bias",
"ln2.weight",
"ln2.bias",
}
state = self.ffn_block.state_dict()
self.assertSetEqual(set(state), expected_keys)
self.assertEqual(tuple(state["mlp.w1.weight"].shape), (30, 12))
self.assertEqual(tuple(state["mlp.w3.weight"].shape), (12, 30))
reloaded = _build_block(TRANSFORMER_FFN_ARCHITECTURE)
incompatible = reloaded.load_state_dict(state, strict=True)
self.assertEqual(incompatible.missing_keys, [])
self.assertEqual(incompatible.unexpected_keys, [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,46 @@
import unittest
import torch
from backbones import TemporalAttention
class TemporalAttentionTest(unittest.TestCase):
def test_zero_rbf_bias_has_live_projection_gradient(self) -> None:
torch.manual_seed(0)
attention = TemporalAttention(
n_embd=12,
n_head=3,
use_time_rope=False,
use_rbf_bias=True,
)
features = torch.randn(2, 4, 4, 16)
target = torch.randn(2, 4, 4, 3)
initial_bias = (
attention.time_bias_scale.tanh()
* attention.rbf_proj(features)
)
torch.testing.assert_close(initial_bias, torch.zeros_like(initial_bias))
(initial_bias * target).sum().backward()
projection_grad = attention.rbf_proj.weight.grad
self.assertIsNotNone(projection_grad)
self.assertGreater(projection_grad.abs().sum().item(), 0.0)
with torch.no_grad():
attention.rbf_proj.weight.add_(projection_grad, alpha=-1e-3)
attention.zero_grad(set_to_none=True)
updated_bias = (
attention.time_bias_scale.tanh()
* attention.rbf_proj(features)
)
(updated_bias * target).sum().backward()
scale_grad = attention.time_bias_scale.grad
self.assertIsNotNone(scale_grad)
self.assertGreater(scale_grad.abs().item(), 0.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -2,13 +2,7 @@ import unittest
import torch
from backbones import GPTBlock, TrajMixer
from models import (
TRAJ_MIXER_ARCHITECTURE,
validate_traj_mixer_config,
validate_traj_mixer_state_dict,
)
from train_util import get_model_parameter_counts
from backbones import TrajMixer
class TrajMixerTest(unittest.TestCase):
@@ -21,20 +15,113 @@ class TrajMixerTest(unittest.TestCase):
x = torch.randn(2, 7, 120)
self.assertEqual(mixer(x).shape, x.shape)
self.assertEqual(sum(p.numel() for p in mixer.parameters()), 15_840)
expected = torch.eye(12).expand(10, 12, 12)
torch.testing.assert_close(mixer.group_align.detach(), expected)
self.assertEqual(sum(p.numel() for p in mixer.parameters()), 32_040)
self.assertEqual(tuple(mixer.norm.normalized_shape), (120,))
self.assertEqual(tuple(mixer.intra_gate_logits.shape), (10, 12))
torch.testing.assert_close(
torch.sigmoid(mixer.intra_gate_logits.detach()),
torch.full((10, 12), 0.1),
)
self.assertEqual(mixer.intra_hidden, 48)
self.assertEqual(
tuple(mixer.intra_gate_proj.shape),
(10, 12, 48),
)
self.assertEqual(
tuple(mixer.intra_value_proj.shape),
(10, 12, 48),
)
self.assertEqual(
tuple(mixer.intra_output_proj.shape),
(10, 48, 12),
)
self.assertEqual(mixer.hidden_group, 40)
self.assertEqual(tuple(mixer.gate_proj.shape), (12, 10, 40))
self.assertEqual(tuple(mixer.value_proj.shape), (12, 10, 40))
self.assertEqual(tuple(mixer.output_proj.shape), (12, 40, 10))
def test_zero_final_output_projection_makes_mixer_identity(self) -> None:
torch.manual_seed(0)
mixer = TrajMixer(12, n_head=3, dropout=0.0)
with torch.no_grad():
mixer.output_proj.zero_()
x = torch.randn(2, 5, 12)
torch.testing.assert_close(mixer(x), x)
def test_forward_matches_single_outer_residual_formula(self) -> None:
torch.manual_seed(0)
mixer = TrajMixer(12, n_head=3, dropout=0.0)
mixer.eval()
x = torch.randn(2, 5, 12)
grouped = mixer.norm(x).reshape(2, 5, 3, 4)
intra_output = mixer._intra_mix(grouped)
static_gate = torch.sigmoid(mixer.intra_gate_logits).view(
1, 1, 3, 4
)
mixed_input = grouped + static_gate * intra_output
update = mixer._cross_mix(mixed_input).reshape(2, 5, 12)
torch.testing.assert_close(mixer(x), x + update)
def test_intra_stage_is_independent_across_groups(self) -> None:
torch.manual_seed(0)
mixer = TrajMixer(12, n_head=3, dropout=0.0)
mixer.eval()
grouped = torch.randn(2, 4, 3, 4)
changed = grouped.clone()
changed[:, :, 1, :] += torch.randn_like(changed[:, :, 1, :])
original_out = mixer._intra_mix(grouped)
changed_out = mixer._intra_mix(changed)
unchanged_groups = torch.tensor([0, 2])
torch.testing.assert_close(
original_out.index_select(2, unchanged_groups),
changed_out.index_select(2, unchanged_groups),
)
def test_cross_stage_mixes_groups_without_mixing_coordinates(self) -> None:
mixer = TrajMixer(6, n_head=3, dropout=0.0)
mixer.eval()
with torch.no_grad():
mixer.gate_proj.zero_()
mixer.value_proj.zero_()
mixer.output_proj.zero_()
# Coordinate 0 reads group 0 through hidden unit 0 and writes it
# into group 1. Coordinate 1 must remain independent.
mixer.gate_proj[0, 0, 0] = 1.0
mixer.value_proj[0, 0, 0] = 1.0
mixer.output_proj[0, 0, 1] = 1.0
grouped = torch.tensor(
[[[
[-1.0, 4.0],
[0.0, 5.0],
[1.0, 6.0],
]]]
)
changed = grouped.clone()
changed[0, 0, 0, 0] = 2.0
original_out = mixer._cross_mix(grouped)
changed_out = mixer._cross_mix(changed)
self.assertNotEqual(
original_out[0, 0, 1, 0].item(),
changed_out[0, 0, 1, 0].item(),
)
torch.testing.assert_close(
original_out[..., 1],
changed_out[..., 1],
)
def test_mixer_does_not_mix_sequence_positions(self) -> None:
torch.manual_seed(0)
mixer = TrajMixer(120, n_head=10, dropout=0.0)
mixer = TrajMixer(12, n_head=3, dropout=0.0)
mixer.eval()
x = torch.randn(2, 5, 120)
x = torch.randn(2, 5, 12)
changed = x.clone()
changed[:, 3, :] += torch.randn_like(changed[:, 3, :])
@@ -46,62 +133,34 @@ class TrajMixerTest(unittest.TestCase):
changed_out.index_select(1, unchanged_positions),
)
def test_gradients_reach_all_projection_families(self) -> None:
def test_gradients_reach_every_projection_family(self) -> None:
torch.manual_seed(1)
mixer = TrajMixer(120, n_head=10, dropout=0.0)
x = torch.randn(2, 4, 120, requires_grad=True)
mixer = TrajMixer(12, n_head=3, dropout=0.0)
x = torch.randn(2, 4, 12, requires_grad=True)
mixer(x).square().mean().backward()
self.assertIsNotNone(x.grad)
for name, parameter in mixer.named_parameters():
self.assertTrue(torch.isfinite(x.grad).all())
self.assertGreater(x.grad.abs().sum().item(), 0.0)
projection_names = (
"intra_gate_proj",
"intra_value_proj",
"intra_output_proj",
"gate_proj",
"value_proj",
"output_proj",
)
for name in projection_names:
parameter = getattr(mixer, name)
self.assertIsNotNone(parameter.grad, name)
self.assertTrue(torch.isfinite(parameter.grad).all(), name)
def test_gpt_block_defaults_to_traj_mixer_and_standard_layer_norm(self) -> None:
block = GPTBlock(n_embd=120, n_head=10)
self.assertIsInstance(block.mlp, TrajMixer)
self.assertIsInstance(block.ln2, torch.nn.LayerNorm)
self.assertEqual(tuple(block.ln2.normalized_shape), (120,))
x = torch.randn(2, 6, 120)
self.assertEqual(block(x).shape, x.shape)
def test_architecture_marker_is_required(self) -> None:
validate_traj_mixer_config(
{"model_architecture": TRAJ_MIXER_ARCHITECTURE}
)
with self.assertRaisesRegex(ValueError, "only accepts models trained"):
validate_traj_mixer_config({})
with self.assertRaisesRegex(ValueError, "only accepts models trained"):
validate_traj_mixer_config({"model_architecture": "delphi_swiglu"})
def test_checkpoint_must_contain_traj_mixer_parameters(self) -> None:
block = GPTBlock(n_embd=120, n_head=10)
state_dict = {
f"blocks.0.{key}": value
for key, value in block.state_dict().items()
}
validate_traj_mixer_state_dict(state_dict)
state_dict.pop("blocks.0.mlp.group_align")
with self.assertRaisesRegex(ValueError, "not a TrajMixer checkpoint"):
validate_traj_mixer_state_dict(state_dict)
self.assertGreater(parameter.grad.abs().sum().item(), 0.0, name)
def test_invalid_group_partition_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "divisible"):
TrajMixer(n_embd=121, n_head=10)
def test_parameter_counts_match_traj_mixer_parameters(self) -> None:
mixer = TrajMixer(n_embd=120, n_head=10)
self.assertEqual(
get_model_parameter_counts(mixer),
{
"model_parameter_count": 15_840,
"trainable_parameter_count": 15_840,
},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -27,7 +27,11 @@ from tqdm.auto import tqdm
from dataset import AllFutureHealthDataset, all_future_collate_fn
from losses import build_loss
from models import TRAJ_MIXER_ARCHITECTURE, DeepHealth
from model_architectures import (
DEFAULT_MODEL_ARCHITECTURE,
SUPPORTED_MODEL_ARCHITECTURES,
)
from models import DeepHealth
from targets import CHECKUP_IDX, PAD_IDX
from train_util import (
configure_torch_for_training,
@@ -65,6 +69,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--data_prefix", type=str, default="ukb")
parser.add_argument("--labels_file", type=str, default="labels.csv")
parser.add_argument("--runs_root", type=str, default="runs")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--extra_info_types_file", type=str, default=None)
@@ -80,8 +85,7 @@ def parse_args() -> argparse.Namespace:
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_layer", type=int, default=12)
parser.add_argument("--n_bins", type=int, default=16)
parser.add_argument("--extra_pool_reduce", type=str, default="mean",
choices=["mean", "sum"])
@@ -90,6 +94,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--dist_mode", type=str, default="exponential",
choices=["exponential", "weibull", "mixed"])
parser.add_argument("--dropout", type=float, default=0.0)
parser.add_argument(
"--model_architecture",
type=str,
default=DEFAULT_MODEL_ARCHITECTURE,
choices=SUPPORTED_MODEL_ARCHITECTURES,
)
parser.add_argument("--batch_size", type=int, default=128)
parser.add_argument("--base_lr", type=float, default=3e-4)
@@ -148,8 +158,7 @@ def build_model(args: argparse.Namespace, dataset: AllFutureHealthDataset) -> De
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_layer=args.n_layer,
n_types=dataset.n_types,
n_cont_types=dataset.n_cont_types,
n_categories=dataset.n_categories,
@@ -160,6 +169,7 @@ def build_model(args: argparse.Namespace, dataset: AllFutureHealthDataset) -> De
time_mode=args.time_mode,
dist_mode=args.dist_mode,
dropout=args.dropout,
model_architecture=args.model_architecture,
)
@@ -299,7 +309,7 @@ def build_metadata(
"dataset_class": "AllFutureHealthDataset",
"collate_fn": "all_future_collate_fn",
"model_class": "DeepHealth",
"model_architecture": TRAJ_MIXER_ARCHITECTURE,
"model_architecture": args.model_architecture,
"model_target_mode": "all_future",
"target_mode": "all_future",
"dist_mode": args.dist_mode,
@@ -337,12 +347,14 @@ def main() -> None:
configure_torch_for_training(device)
run_dir, run_name = create_unique_run_dir(
lambda timestamp: f"{args.time_mode}_{args.dist_mode}_all_future_pure_disease_{timestamp}"
lambda timestamp: f"{args.time_mode}_{args.dist_mode}_all_future_pure_disease_{timestamp}",
runs_root=Path(args.runs_root) / args.model_architecture,
)
logger = setup_logging(run_dir)
logger.info(f"Starting all-future training run: {run_name}")
logger.info(f"Device: {device}")
logger.info(f"Model architecture: {args.model_architecture}")
logger.info(f"extra_info_types: {format_extra_info_types(args.extra_info_types)}")
logger.info("Loading all-future datasets...")

View File

@@ -24,7 +24,11 @@ from tqdm.auto import tqdm
from dataset import HealthDataset, collate_fn
from losses import build_loss
from models import TRAJ_MIXER_ARCHITECTURE, DeepHealth, DeepHealthOutput
from model_architectures import (
DEFAULT_MODEL_ARCHITECTURE,
SUPPORTED_MODEL_ARCHITECTURES,
)
from models import DeepHealth, DeepHealthOutput
from readouts import build_readout
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX
from train_util import (
@@ -62,6 +66,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--data_prefix", type=str, default="ukb")
parser.add_argument("--labels_file", type=str, default="labels.csv")
parser.add_argument("--runs_root", type=str, default="runs")
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)
@@ -76,14 +81,19 @@ def parse_args() -> argparse.Namespace:
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_layer", type=int, default=12)
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(
"--model_architecture",
type=str,
default=DEFAULT_MODEL_ARCHITECTURE,
choices=SUPPORTED_MODEL_ARCHITECTURES,
)
parser.add_argument("--target_mode", type=str, default="uts",
choices=["delphi2m", "uts"])
@@ -153,8 +163,7 @@ def build_model(args: argparse.Namespace, dataset: HealthDataset) -> 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_layer=args.n_layer,
n_types=dataset.n_types,
n_cont_types=dataset.n_cont_types,
n_categories=dataset.n_categories,
@@ -165,6 +174,7 @@ def build_model(args: argparse.Namespace, dataset: HealthDataset) -> DeepHealth:
time_mode=args.time_mode,
dist_mode="exponential",
dropout=args.dropout,
model_architecture=args.model_architecture,
)
@@ -485,7 +495,7 @@ def build_metadata(
"dataset_class": "NextStepHealthDataset",
"collate_fn": "next_step_collate_fn",
"model_class": "DeepHealth",
"model_architecture": TRAJ_MIXER_ARCHITECTURE,
"model_architecture": args.model_architecture,
"model_target_mode": "next_token",
"target_mode": args.target_mode,
"dist_mode": "exponential",
@@ -523,12 +533,14 @@ def main() -> None:
lambda timestamp: (
f"{args.time_mode}_exponential_next_token_{args.target_mode}_"
f"gap_{args.no_event_interval_years:g}y_{timestamp}"
)
),
runs_root=Path(args.runs_root) / args.model_architecture,
)
logger = setup_logging(run_dir)
logger.info(f"Starting next-step training run: {run_name}")
logger.info(f"Device: {device}")
logger.info(f"Model architecture: {args.model_architecture}")
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}")

View File

@@ -301,7 +301,7 @@ def build_optimizer(args: Any, model: DeepHealth) -> AdamW:
def get_model_parameter_counts(model: torch.nn.Module) -> Dict[str, int]:
"""Return stable parameter-count fields for logs and train_config.json."""
"""Return stable total and trainable parameter counts."""
return {
"model_parameter_count": sum(
parameter.numel() for parameter in model.parameters()