Compare commits
7 Commits
codex/unif
...
85352dae0f
| Author | SHA1 | Date | |
|---|---|---|---|
| 85352dae0f | |||
| 22faee7c51 | |||
| 6f7b5be405 | |||
| 06f29c0f0a | |||
| 68a6a3df88 | |||
| 978c88a4ed | |||
| db0947ce9d |
@@ -1,64 +0,0 @@
|
||||
# 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.
|
||||
333
TrajMixer_设计方案.md
Normal file
333
TrajMixer_设计方案.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# 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 的全维度 SwiGLU(gate/value/output 三个线性层),每层共有 108,720 个参数(含 bias)。代码实验和 checkpoint 参数量比较必须以 108,720 作为历史实现基线,不能与概念方案中的标准 FFN 参数量混用。
|
||||
|
||||
## 8. LayerNorm 基线与消融
|
||||
|
||||
为保持与原始 Transformer 的可比性,首版固定使用:
|
||||
|
||||
```text
|
||||
原始 FFN baseline:FFN + 标准 LayerNorm
|
||||
TrajMixer baseline:Mixer + 标准 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}}.
|
||||
\]
|
||||
|
||||
后续实现、单元测试、参数量核验和主实验均以以上配置为默认基线。
|
||||
212
backbones.py
212
backbones.py
@@ -4,12 +4,6 @@ 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):
|
||||
@@ -117,10 +111,7 @@ 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)
|
||||
# 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.time_bias_scale = nn.Parameter(torch.tensor(0.0))
|
||||
|
||||
self.resid_drop = nn.Dropout(dropout)
|
||||
self.reset_parameters()
|
||||
@@ -185,42 +176,8 @@ 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):
|
||||
"""PreNorm gated mixing within and across latent trajectory groups.
|
||||
"""Lightweight gated interaction across latent residual-space groups.
|
||||
|
||||
The groups are contiguous partitions of the post-``W_O`` residual
|
||||
representation. They are deliberately not treated as attention heads.
|
||||
@@ -244,26 +201,19 @@ 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
|
||||
|
||||
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-group feature alignment: [group, input feature, output feature].
|
||||
self.group_align = nn.Parameter(
|
||||
torch.empty(self.n_group, self.d_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)
|
||||
)
|
||||
@@ -277,116 +227,57 @@ class TrajMixer(nn.Module):
|
||||
self.reset_parameters()
|
||||
|
||||
def reset_parameters(self) -> None:
|
||||
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),
|
||||
)
|
||||
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))
|
||||
|
||||
# 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:
|
||||
"""Apply one full-width PreNorm and one outer residual update."""
|
||||
"""Map ``(B, L, n_embd)`` to an equally shaped 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 = self.norm(x).reshape(
|
||||
grouped = x.reshape(
|
||||
batch_size, seq_len, self.n_group, self.d_group
|
||||
)
|
||||
|
||||
intra_output = self._intra_mix(grouped)
|
||||
intra_gate = torch.sigmoid(self.intra_gate_logits).view(
|
||||
1, 1, self.n_group, self.d_group
|
||||
aligned = torch.einsum(
|
||||
"blgd,gde->blge", grouped, self.group_align
|
||||
)
|
||||
mixed_input = grouped + intra_gate * intra_output
|
||||
|
||||
update = self._cross_mix(mixed_input).reshape(
|
||||
batch_size, seq_len, self.n_embd
|
||||
gate = torch.einsum(
|
||||
"blgr,rgh->blhr", aligned, self.gate_proj
|
||||
)
|
||||
return x + self.drop(update)
|
||||
value = torch.einsum(
|
||||
"blgr,rgh->blhr", aligned, self.value_proj
|
||||
)
|
||||
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))
|
||||
|
||||
|
||||
class TransformerFFNBlock(nn.Module):
|
||||
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 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,
|
||||
@@ -408,6 +299,7 @@ class TrajMixerBlock(nn.Module):
|
||||
dropout=mlp_dropout,
|
||||
)
|
||||
self.ln1 = nn.LayerNorm(n_embd)
|
||||
self.ln2 = nn.LayerNorm(n_embd)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
@@ -417,38 +309,8 @@ class TrajMixerBlock(nn.Module):
|
||||
attn_mask: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
x = x + self.attn(self.ln1(x), rope_cache, rbf_cache, attn_mask)
|
||||
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,
|
||||
)
|
||||
x = x + self.mlp(self.ln2(x))
|
||||
return x
|
||||
|
||||
|
||||
class TokenAutoDiscretization(nn.Module):
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
"""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",
|
||||
],
|
||||
]
|
||||
@@ -7,8 +7,7 @@ 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. average age-bracket AUCs within each sex and write a Delphi2M-style
|
||||
Female/Male report.
|
||||
5. aggregate age brackets with DeLong variance.
|
||||
|
||||
Efficiency notes:
|
||||
- transformer/readout inference is executed once and cached;
|
||||
@@ -40,13 +39,12 @@ from torch.utils.data import DataLoader, Subset
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from dataset import HealthDataset
|
||||
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 models import (
|
||||
DeepHealth,
|
||||
validate_traj_mixer_config,
|
||||
validate_traj_mixer_state_dict,
|
||||
)
|
||||
from readouts import build_readout
|
||||
from targets import PAD_IDX, CHECKUP_IDX, NO_EVENT_IDX
|
||||
|
||||
@@ -314,24 +312,20 @@ 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,
|
||||
state_dict: Optional[Dict[str, Any]] = None,
|
||||
) -> DeepHealth:
|
||||
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
|
||||
validate_traj_mixer_config(cfg)
|
||||
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_layer=int(cfg["n_layer"]),
|
||||
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_types=dataset.n_types,
|
||||
n_cont_types=dataset.n_cont_types,
|
||||
n_categories=dataset.n_categories,
|
||||
@@ -342,7 +336,6 @@ def build_model_from_dataset(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -390,7 +383,7 @@ def resolve_dist_mode_for_checkpoint(cfg_dist_mode: str, state_dict: Dict[str, A
|
||||
|
||||
|
||||
def load_model_state(
|
||||
model: DeepHealth,
|
||||
model: torch.nn.Module,
|
||||
checkpoint_path: str,
|
||||
device: torch.device,
|
||||
state_dict: Optional[Dict[str, Any]] = None,
|
||||
@@ -398,7 +391,7 @@ def load_model_state(
|
||||
state = state_dict if state_dict is not None else load_checkpoint_state_dict(
|
||||
checkpoint_path, map_location=device)
|
||||
|
||||
resolve_model_architecture(model.model_architecture, state)
|
||||
validate_traj_mixer_state_dict(state)
|
||||
model.load_state_dict(state, strict=True)
|
||||
|
||||
|
||||
@@ -1171,23 +1164,30 @@ def evaluate_auc_pipeline(
|
||||
df_auc_unpooled["label_code"] = df_auc_unpooled["token"].map(
|
||||
dataset.label_id_to_code)
|
||||
|
||||
print(
|
||||
"Building Delphi2M-style report: mean AUC across age strata, "
|
||||
"reported separately for Female and Male."
|
||||
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"),
|
||||
)
|
||||
df_report = build_delphi2m_auc_report(
|
||||
df_auc_unpooled,
|
||||
period_col="offset",
|
||||
df_auc["auc_variance_delong"] = (
|
||||
df_auc["auc_variance_sum"]
|
||||
/ (df_auc["n_strata"].clip(lower=1).astype(np.float64) ** 2)
|
||||
)
|
||||
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)
|
||||
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}")
|
||||
df_auc.to_csv(out_dir / "df_both.csv", index=False)
|
||||
df_auc_unpooled.to_csv(
|
||||
out_dir / "df_auc_unpooled.csv", index=False)
|
||||
|
||||
return df_auc_unpooled, df_report
|
||||
return df_auc_unpooled, df_auc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1237,18 +1237,8 @@ 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",
|
||||
DEFAULT_DELPHI2M_PERIODS_YEARS[0],
|
||||
)
|
||||
)
|
||||
base_offsets = [
|
||||
next_token_offset,
|
||||
*DEFAULT_DELPHI2M_PERIODS_YEARS[1:],
|
||||
]
|
||||
next_token_offset = float(cfg_get(args, cfg, "offset", 0.1))
|
||||
base_offsets = [next_token_offset, 1.0, 5.0, 10.0]
|
||||
|
||||
offsets: List[float] = []
|
||||
seen = set()
|
||||
@@ -1296,9 +1286,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; 0.1 is Delphi2M no gap and is evaluated alongside 1, 5, and 10 years by default.")
|
||||
help="Next-token prediction offset in years; preserved and 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 0.1,1,5,10.")
|
||||
help="Comma-separated prediction offsets in years. Overrides the default set of offset,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)
|
||||
@@ -1379,19 +1369,14 @@ 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, state_dict=state_dict
|
||||
).to(device)
|
||||
model = build_model_from_dataset(args, cfg, dataset).to(device)
|
||||
load_model_state(model, str(model_ckpt_path),
|
||||
device, state_dict=state_dict)
|
||||
model.eval()
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
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.
|
||||
@@ -31,13 +28,12 @@ from torch.utils.data import DataLoader, Dataset
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from dataset import HealthDataset
|
||||
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 models import (
|
||||
DeepHealth,
|
||||
validate_traj_mixer_config,
|
||||
validate_traj_mixer_state_dict,
|
||||
)
|
||||
from readouts import build_readout
|
||||
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX
|
||||
|
||||
@@ -185,24 +181,20 @@ 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,
|
||||
state_dict: Optional[Dict[str, Any]] = None,
|
||||
) -> DeepHealth:
|
||||
def build_model_from_dataset(args: argparse.Namespace, cfg: Dict[str, Any], dataset: HealthDataset) -> DeepHealth:
|
||||
validate_traj_mixer_config(cfg)
|
||||
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_layer=int(cfg["n_layer"]),
|
||||
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_types=dataset.n_types,
|
||||
n_cont_types=dataset.n_cont_types,
|
||||
n_categories=dataset.n_categories,
|
||||
@@ -213,12 +205,11 @@ def build_model_from_dataset(
|
||||
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: DeepHealth, state_dict: Dict[str, Any]) -> None:
|
||||
resolve_model_architecture(model.model_architecture, state_dict)
|
||||
def load_model_state(model: torch.nn.Module, state_dict: Dict[str, Any]) -> None:
|
||||
validate_traj_mixer_state_dict(state_dict)
|
||||
model.load_state_dict(state_dict, strict=True)
|
||||
|
||||
|
||||
@@ -339,6 +330,44 @@ 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 {}
|
||||
@@ -1078,6 +1107,7 @@ 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,
|
||||
@@ -1094,6 +1124,7 @@ 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)
|
||||
|
||||
@@ -1210,21 +1241,54 @@ def evaluate_landmark_auc(
|
||||
df_unpooled["label_code"] = df_unpooled["token"].map(
|
||||
landmark_dataset.dataset.label_id_to_code)
|
||||
|
||||
print(
|
||||
"Building Delphi2M-style report: mean AUC across landmark-age "
|
||||
"strata, reported separately for Female and Male."
|
||||
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"),
|
||||
)
|
||||
df_report = build_delphi2m_auc_report(
|
||||
df_unpooled,
|
||||
period_col="horizon",
|
||||
df_merged["auc_variance_delong"] = (
|
||||
df_merged["auc_variance_sum"]
|
||||
/ (df_merged["n_strata"].clip(lower=1).astype(np.float64) ** 2)
|
||||
)
|
||||
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)
|
||||
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}")
|
||||
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)
|
||||
|
||||
return df_unpooled, df_report
|
||||
return df_unpooled, df_merged
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -1250,12 +1314,7 @@ 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,
|
||||
help="Comma-separated horizons in years; defaults to 0.1,1,5,10, where 0.1 is Delphi2M no gap.",
|
||||
)
|
||||
parser.add_argument("--horizons", type=str, default=None)
|
||||
|
||||
parser.add_argument("--min_cases", type=int, default=None)
|
||||
parser.add_argument("--min_history_events", type=int, default=None)
|
||||
@@ -1375,9 +1434,8 @@ 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", "0.1,1,5,10")
|
||||
) or list(DEFAULT_DELPHI2M_PERIODS_YEARS),
|
||||
parse_float_list(cfg_get(args, cfg, "horizons", "1,5,10")) or [
|
||||
1.0, 5.0, 10.0],
|
||||
dtype=np.float32,
|
||||
)
|
||||
if horizons.size == 0:
|
||||
@@ -1400,17 +1458,12 @@ 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, state_dict=state_dict
|
||||
).to(device)
|
||||
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
||||
|
||||
if (
|
||||
model_target_mode == "next_token"
|
||||
@@ -1473,6 +1526,8 @@ 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))
|
||||
@@ -1504,11 +1559,27 @@ 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,
|
||||
@@ -1525,6 +1596,7 @@ def main() -> None:
|
||||
use_amp=use_amp,
|
||||
hidden_cache_dtype=hidden_cache_dtype,
|
||||
logit_batch_size=logit_batch_size,
|
||||
meta_info=meta_info,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -649,9 +649,7 @@ 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, state_dict=state_dict
|
||||
).to(device)
|
||||
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
||||
load_model_state(model, state_dict)
|
||||
model.eval()
|
||||
|
||||
|
||||
@@ -758,9 +758,7 @@ 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, state_dict=state_dict
|
||||
).to(device)
|
||||
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
||||
load_model_state(model, state_dict)
|
||||
model.eval()
|
||||
|
||||
|
||||
@@ -553,9 +553,7 @@ 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, state_dict=state_dict
|
||||
).to(device)
|
||||
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
||||
load_model_state(model, state_dict)
|
||||
model.eval()
|
||||
|
||||
|
||||
@@ -180,9 +180,7 @@ 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, state_dict=state_dict
|
||||
).to(device)
|
||||
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
||||
load_model_state(model, state_dict)
|
||||
model.eval()
|
||||
|
||||
|
||||
@@ -381,9 +381,7 @@ 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, state_dict=state_dict
|
||||
).to(device)
|
||||
model = build_model_from_dataset(args, cfg_model, dataset).to(device)
|
||||
load_model_state(model, state_dict)
|
||||
model.eval()
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""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
|
||||
50
models.py
50
models.py
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
@@ -6,15 +7,41 @@ 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
|
||||
@@ -148,7 +175,8 @@ class DeepHealth(nn.Module):
|
||||
vocab_size: int,
|
||||
n_embd: int,
|
||||
n_head: int,
|
||||
n_layer: int,
|
||||
n_hist_layer: int,
|
||||
n_tab_layer: int,
|
||||
n_types: int,
|
||||
n_cont_types: int,
|
||||
n_categories: int,
|
||||
@@ -160,7 +188,6 @@ 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"]:
|
||||
@@ -174,9 +201,6 @@ 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
|
||||
@@ -193,8 +217,6 @@ 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)
|
||||
@@ -214,28 +236,26 @@ class DeepHealth(nn.Module):
|
||||
if time_mode == "absolute":
|
||||
self.age_encoding = AgeSinusoidalEncoding(n_embd)
|
||||
self.blocks = nn.ModuleList([
|
||||
build_backbone_block(
|
||||
model_architecture,
|
||||
GPTBlock(
|
||||
n_embd=n_embd,
|
||||
n_head=n_head,
|
||||
use_time_rope=False,
|
||||
use_rbf_bias=False,
|
||||
mlp_dropout=dropout,
|
||||
) for _ in range(n_layer)
|
||||
) for _ in range(n_hist_layer)
|
||||
])
|
||||
self.rope = None
|
||||
self.rbf = None
|
||||
elif time_mode == "relative":
|
||||
self.age_encoding = None
|
||||
self.blocks = nn.ModuleList([
|
||||
build_backbone_block(
|
||||
model_architecture,
|
||||
GPTBlock(
|
||||
n_embd=n_embd,
|
||||
n_head=n_head,
|
||||
use_time_rope=True,
|
||||
use_rbf_bias=True,
|
||||
mlp_dropout=dropout,
|
||||
) for _ in range(n_layer)
|
||||
) for _ in range(n_hist_layer)
|
||||
])
|
||||
self.rope = TimeRoPE(n_embd // n_head)
|
||||
self.rbf = GaussianRBFTimeBasis(n_bases=16, max_time_diff=40.0)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 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.
|
||||
# Run all non-wrapper evaluation scripts for every completed 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}"
|
||||
@@ -82,20 +81,6 @@ 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
|
||||
@@ -130,30 +115,8 @@ raise SystemExit(0 if mode == "all_future" else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
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}"
|
||||
for run_path in runs/*; do
|
||||
[[ -d "${run_path}" ]] || continue
|
||||
|
||||
echo "==> ${run_path}"
|
||||
if [[ ! -f "${run_path}/train_config.json" ]]; then
|
||||
@@ -164,10 +127,6 @@ for config_path in runs/**/train_config.json; 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}")
|
||||
@@ -178,16 +137,18 @@ for config_path in runs/**/train_config.json; do
|
||||
cpu_reduce_extra=()
|
||||
while IFS= read -r arg; do cpu_reduce_extra+=("${arg}"); done < <(cpu_reduce_args)
|
||||
|
||||
run_file_result_if_missing \
|
||||
run_dir_result_if_missing \
|
||||
"evaluate_auc.py" \
|
||||
"${run_path}" \
|
||||
"df_auc_delphi2m_report.csv" \
|
||||
"df_both.csv" \
|
||||
"df_auc_unpooled.csv" \
|
||||
"${PYTHON_BIN}" evaluate_auc.py "${common[@]}" "${auc_extra[@]}"
|
||||
|
||||
run_file_result_if_missing \
|
||||
run_dir_result_if_missing \
|
||||
"evaluate_auc_v2.py" \
|
||||
"${run_path}" \
|
||||
"df_auc_landmark_delphi2m_report.csv" \
|
||||
"df_auc_landmark.csv" \
|
||||
"df_auc_landmark_unpooled.csv" \
|
||||
"${PYTHON_BIN}" evaluate_auc_v2.py "${common[@]}" "${auc_extra[@]}"
|
||||
|
||||
if ! run_is_all_future "${run_path}"; then
|
||||
|
||||
@@ -10,8 +10,7 @@ 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. Set
|
||||
# MODEL_ARCHITECTURE=traj_mixer_v5 to run the TrajMixer variant.
|
||||
# not call evaluate_*.py and does not add extra random seeds.
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
@@ -19,8 +18,6 @@ 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"
|
||||
@@ -39,8 +36,8 @@ COMMON_ARGS=(
|
||||
--min_future_events 1
|
||||
--n_embd 120
|
||||
--n_head 10
|
||||
--n_layer "${N_LAYER}"
|
||||
--model_architecture "${MODEL_ARCHITECTURE}"
|
||||
--n_hist_layer 12
|
||||
--n_tab_layer 4
|
||||
--n_bins 16
|
||||
--extra_pool_reduce mean
|
||||
--dropout 0.0
|
||||
@@ -60,23 +57,15 @@ COMMON_ARGS=(
|
||||
|
||||
already_trained() {
|
||||
local extra_file="$1"
|
||||
"${PYTHON_BIN}" - "$TIME_MODE" "$DIST_MODE" "$extra_file" "$SEED" "$VALIDATION_QUERY_SEED" "$MODEL_ARCHITECTURE" "$N_LAYER" <<'PY'
|
||||
"${PYTHON_BIN}" - "$TIME_MODE" "$DIST_MODE" "$extra_file" "$SEED" "$VALIDATION_QUERY_SEED" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
(
|
||||
time_mode,
|
||||
dist_mode,
|
||||
extra_file,
|
||||
seed,
|
||||
validation_query_seed,
|
||||
model_architecture,
|
||||
n_layer,
|
||||
) = sys.argv[1:8]
|
||||
time_mode, dist_mode, extra_file, seed, validation_query_seed = sys.argv[1:6]
|
||||
extra_name = Path(extra_file).name
|
||||
|
||||
for config_path in Path("runs").rglob("train_config.json"):
|
||||
for config_path in Path("runs").glob("*/train_config.json"):
|
||||
try:
|
||||
cfg = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
@@ -89,8 +78,6 @@ for config_path in Path("runs").rglob("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
|
||||
@@ -113,7 +100,7 @@ train_if_missing() {
|
||||
return 2
|
||||
fi
|
||||
|
||||
echo "==> Checking ${label}: ${MODEL_ARCHITECTURE} n_layer=${N_LAYER} ${TIME_MODE} ${DIST_MODE} all_future with ${extra_file}"
|
||||
echo "==> Checking ${label}: ${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
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
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()
|
||||
@@ -1,46 +0,0 @@
|
||||
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()
|
||||
@@ -2,7 +2,13 @@ import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from backbones import TrajMixer
|
||||
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
|
||||
|
||||
|
||||
class TrajMixerTest(unittest.TestCase):
|
||||
@@ -15,113 +21,20 @@ 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()), 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(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(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(12, n_head=3, dropout=0.0)
|
||||
mixer = TrajMixer(120, n_head=10, dropout=0.0)
|
||||
mixer.eval()
|
||||
x = torch.randn(2, 5, 12)
|
||||
x = torch.randn(2, 5, 120)
|
||||
changed = x.clone()
|
||||
changed[:, 3, :] += torch.randn_like(changed[:, 3, :])
|
||||
|
||||
@@ -133,34 +46,62 @@ class TrajMixerTest(unittest.TestCase):
|
||||
changed_out.index_select(1, unchanged_positions),
|
||||
)
|
||||
|
||||
def test_gradients_reach_every_projection_family(self) -> None:
|
||||
def test_gradients_reach_all_projection_families(self) -> None:
|
||||
torch.manual_seed(1)
|
||||
mixer = TrajMixer(12, n_head=3, dropout=0.0)
|
||||
x = torch.randn(2, 4, 12, requires_grad=True)
|
||||
mixer = TrajMixer(120, n_head=10, dropout=0.0)
|
||||
x = torch.randn(2, 4, 120, requires_grad=True)
|
||||
|
||||
mixer(x).square().mean().backward()
|
||||
|
||||
self.assertIsNotNone(x.grad)
|
||||
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)
|
||||
for name, parameter in mixer.named_parameters():
|
||||
self.assertIsNotNone(parameter.grad, name)
|
||||
self.assertTrue(torch.isfinite(parameter.grad).all(), name)
|
||||
self.assertGreater(parameter.grad.abs().sum().item(), 0.0, 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)
|
||||
|
||||
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()
|
||||
|
||||
@@ -27,11 +27,7 @@ from tqdm.auto import tqdm
|
||||
|
||||
from dataset import AllFutureHealthDataset, all_future_collate_fn
|
||||
from losses import build_loss
|
||||
from model_architectures import (
|
||||
DEFAULT_MODEL_ARCHITECTURE,
|
||||
SUPPORTED_MODEL_ARCHITECTURES,
|
||||
)
|
||||
from models import DeepHealth
|
||||
from models import TRAJ_MIXER_ARCHITECTURE, DeepHealth
|
||||
from targets import CHECKUP_IDX, PAD_IDX
|
||||
from train_util import (
|
||||
configure_torch_for_training,
|
||||
@@ -69,7 +65,6 @@ 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)
|
||||
|
||||
@@ -85,7 +80,8 @@ 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_layer", type=int, default=12)
|
||||
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"])
|
||||
@@ -94,12 +90,6 @@ 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)
|
||||
@@ -158,7 +148,8 @@ 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_layer=args.n_layer,
|
||||
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,
|
||||
@@ -169,7 +160,6 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -309,7 +299,7 @@ def build_metadata(
|
||||
"dataset_class": "AllFutureHealthDataset",
|
||||
"collate_fn": "all_future_collate_fn",
|
||||
"model_class": "DeepHealth",
|
||||
"model_architecture": args.model_architecture,
|
||||
"model_architecture": TRAJ_MIXER_ARCHITECTURE,
|
||||
"model_target_mode": "all_future",
|
||||
"target_mode": "all_future",
|
||||
"dist_mode": args.dist_mode,
|
||||
@@ -347,14 +337,12 @@ 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}",
|
||||
runs_root=Path(args.runs_root) / args.model_architecture,
|
||||
lambda timestamp: f"{args.time_mode}_{args.dist_mode}_all_future_pure_disease_{timestamp}"
|
||||
)
|
||||
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...")
|
||||
|
||||
@@ -24,11 +24,7 @@ from tqdm.auto import tqdm
|
||||
|
||||
from dataset import HealthDataset, collate_fn
|
||||
from losses import build_loss
|
||||
from model_architectures import (
|
||||
DEFAULT_MODEL_ARCHITECTURE,
|
||||
SUPPORTED_MODEL_ARCHITECTURES,
|
||||
)
|
||||
from models import DeepHealth, DeepHealthOutput
|
||||
from models import TRAJ_MIXER_ARCHITECTURE, DeepHealth, DeepHealthOutput
|
||||
from readouts import build_readout
|
||||
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX
|
||||
from train_util import (
|
||||
@@ -66,7 +62,6 @@ 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)
|
||||
@@ -81,19 +76,14 @@ 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_layer", type=int, default=12)
|
||||
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(
|
||||
"--model_architecture",
|
||||
type=str,
|
||||
default=DEFAULT_MODEL_ARCHITECTURE,
|
||||
choices=SUPPORTED_MODEL_ARCHITECTURES,
|
||||
)
|
||||
|
||||
parser.add_argument("--target_mode", type=str, default="uts",
|
||||
choices=["delphi2m", "uts"])
|
||||
@@ -163,7 +153,8 @@ 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_layer=args.n_layer,
|
||||
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,
|
||||
@@ -174,7 +165,6 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -495,7 +485,7 @@ def build_metadata(
|
||||
"dataset_class": "NextStepHealthDataset",
|
||||
"collate_fn": "next_step_collate_fn",
|
||||
"model_class": "DeepHealth",
|
||||
"model_architecture": args.model_architecture,
|
||||
"model_architecture": TRAJ_MIXER_ARCHITECTURE,
|
||||
"model_target_mode": "next_token",
|
||||
"target_mode": args.target_mode,
|
||||
"dist_mode": "exponential",
|
||||
@@ -533,14 +523,12 @@ 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}")
|
||||
|
||||
|
||||
@@ -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 total and trainable parameter counts."""
|
||||
"""Return stable parameter-count fields for logs and train_config.json."""
|
||||
return {
|
||||
"model_parameter_count": sum(
|
||||
parameter.numel() for parameter in model.parameters()
|
||||
|
||||
Reference in New Issue
Block a user