MAOMAO / model.py
luan0519's picture
Upload 15 files
32a6969 verified
Raw History Blame Contribute Delete
38.6 kB
"""MAOMAO event-time transformer for sparse perioperative trajectories."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Dict, Optional
import torch
from torch import nn
from torch.nn import functional as F
@dataclass
class EventMAOMAOOutput:
logits: Optional[torch.Tensor]
family_logits: Optional[torch.Tensor] = None
log_total_rate: Optional[torch.Tensor] = None
time_mu: Optional[torch.Tensor] = None
time_log_sigma: Optional[torch.Tensor] = None
family_time_mu: Optional[torch.Tensor] = None
family_time_log_sigma: Optional[torch.Tensor] = None
fine_hazard_logits: Optional[torch.Tensor] = None
long_hazard_logits: Optional[torch.Tensor] = None
tail_mu: Optional[torch.Tensor] = None
tail_log_sigma: Optional[torch.Tensor] = None
trajectory_logits: Optional[torch.Tensor] = None
token_logits: Optional[torch.Tensor] = None
value_prediction: Optional[torch.Tensor] = None
hidden_state: Optional[torch.Tensor] = None
class ContinuousTimeEncoding(nn.Module):
def __init__(self, hidden_dim: int, enhanced: bool = False):
super().__init__()
half = hidden_dim // 2
frequencies = torch.exp(torch.linspace(math.log(1 / 5), math.log(1 / 43200), half))
self.register_buffer("frequencies", frequencies)
self.projection = nn.Linear(half * 4, hidden_dim, bias=False)
self.scale_projection = (nn.Sequential(
nn.Linear(4, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim, bias=False)
) if enhanced else None)
def forward(self, absolute_minutes: torch.Tensor, gap_minutes: torch.Tensor) -> torch.Tensor:
absolute = absolute_minutes[..., None] * self.frequencies
gap = gap_minutes[..., None] * self.frequencies
features = torch.cat((absolute.sin(), absolute.cos(), gap.sin(), gap.cos()), dim=-1)
periodic = self.projection(features)
if self.scale_projection is None:
return periodic
scale = math.log1p(43200.0)
monotonic = torch.stack((
torch.log1p(absolute_minutes.clamp_min(0)) / scale,
torch.log1p(gap_minutes.clamp_min(0)) / scale,
(gap_minutes > 0).to(absolute_minutes.dtype),
(absolute_minutes / 43200.0).clamp(0, 2),
), dim=-1)
return periodic + self.scale_projection(monotonic)
def dual_timescale_expected_wait(fine_hazard_logits: torch.Tensor,
long_hazard_logits: torch.Tensor,
tail_mu: torch.Tensor,
tail_log_sigma: torch.Tensor) -> torch.Tensor:
"""Decode event-specific expected waiting hours from the dual hazard head.
The output shape is ``[..., num_outcomes]``. Fine hazards cover 0--2 h in
five-minute bins, long hazards cover the configurable 2 h onward interval
in thirty-minute bins, and the log-normal tail starts after the long head.
"""
fine_probability = torch.sigmoid(fine_hazard_logits.float())
fine_log_survival = F.logsigmoid(-fine_hazard_logits.float())
fine_previous = torch.exp(torch.cat((
torch.zeros_like(fine_log_survival[..., :1]),
fine_log_survival[..., :-1].cumsum(-1)), dim=-1))
fine_edges = (5.0 / 60.0) * torch.arange(
fine_hazard_logits.shape[-1] + 1, device=fine_hazard_logits.device,
dtype=fine_hazard_logits.dtype)
fine_midpoints = (fine_edges[:-1] + fine_edges[1:]) / 2.0
fine_mean = (fine_previous * fine_probability * fine_midpoints).sum(-1)
survival_after_fine = torch.exp(fine_log_survival.sum(-1))
long_probability = torch.sigmoid(long_hazard_logits.float())
long_log_survival = F.logsigmoid(-long_hazard_logits.float())
long_previous = torch.exp(torch.cat((
torch.zeros_like(long_log_survival[..., :1]),
long_log_survival[..., :-1].cumsum(-1)), dim=-1))
long_edges = 2.0 + 0.5 * torch.arange(
long_hazard_logits.shape[-1] + 1, device=long_hazard_logits.device,
dtype=long_hazard_logits.dtype)
long_midpoints = (long_edges[:-1] + long_edges[1:]) / 2.0
long_mean = (long_previous * long_probability * long_midpoints).sum(-1)
survival_after_long = torch.exp(long_log_survival.sum(-1))
long_end = 2.0 + 0.5 * long_hazard_logits.shape[-1]
# Keep diagnostic decoding numerically consistent with the bounded
# log-normal tail used by the training loss. An unconstrained sigma can
# otherwise make exp(mu + sigma^2 / 2) overflow and produce meaningless
# multi-million-hour MAE values even though the hazard logits are finite.
tail_sigma = tail_log_sigma.float().clamp(-3.0, 2.0).exp()
tail_mu_safe = tail_mu.float().clamp(-10.0, 10.0)
tail_mean = long_end + torch.exp(
tail_mu_safe + 0.5 * tail_sigma.square()).clamp_max(24.0 * 38.0)
return (fine_mean + survival_after_fine * (
long_mean + survival_after_long * tail_mean)
).clamp_min(1.0 / 60.0).clamp_max(24.0 * 38.0)
class EventMAOMAO(nn.Module):
"""Causal transformer predicting the next outcome set and waiting time.
Output logits are interpreted as log cause-specific rates. Their softmax
predicts event identity; their log-sum-exp predicts the total event rate,
following Delphi's competing-exponentials formulation.
"""
def __init__(self, num_tokens: int, num_outcomes: int, num_static: int = 2,
hidden_dim: int = 256, num_layers: int = 8, num_heads: int = 8,
ffn_dim: int = 1024, dropout: float = 0.1,
initial_event_interval_hours: float = 24.0,
decoupled_time_head: bool = False,
enhanced_time_encoding: bool = False,
num_trajectory_horizons: int = 3,
lognormal_time_head: bool = False,
outcome_family_ids: Optional[torch.Tensor] = None,
use_family_head: bool = True,
same_time_block_causal: bool = False,
relative_time_attention: bool = False,
event_conditioned_time_head: bool = False,
phase_memory: bool = False,
clock_phase_context: bool = True,
observation_intensity: bool = False,
value_reconstruction: bool = False,
dual_timescale_time_head: bool = False,
fine_time_bins: int = 24,
long_time_bins: int = 8,
context_segment_length: int = 0,
model_family_count: int | None = None):
super().__init__()
if hidden_dim % num_heads:
raise ValueError("hidden_dim must be divisible by num_heads")
self.num_outcomes = int(num_outcomes)
self.num_heads = int(num_heads)
self.decoupled_time_head = bool(decoupled_time_head)
self.use_family_head = bool(use_family_head)
self.lognormal_time_head = bool(lognormal_time_head)
self.same_time_block_causal = bool(same_time_block_causal)
self.relative_time_attention = bool(relative_time_attention)
self.event_conditioned_time_head = bool(event_conditioned_time_head)
self.phase_memory = bool(phase_memory)
self.clock_phase_context = bool(clock_phase_context)
self.observation_intensity = bool(observation_intensity)
self.value_reconstruction = bool(value_reconstruction)
self.dual_timescale_time_head = bool(dual_timescale_time_head)
self.fine_time_bins = int(fine_time_bins)
self.long_time_bins = int(long_time_bins)
self.context_segment_length = int(context_segment_length)
if self.context_segment_length < 0:
raise ValueError("context_segment_length must be non-negative")
if self.dual_timescale_time_head and (self.fine_time_bins <= 0 or self.long_time_bins <= 0):
raise ValueError("dual-timescale hazard heads require positive bin counts")
if outcome_family_ids is None:
family_ids = torch.empty(0, dtype=torch.long)
self.num_event_families = 0
else:
family_ids = torch.as_tensor(outcome_family_ids, dtype=torch.long)
if family_ids.shape != (self.num_outcomes,):
raise ValueError("outcome_family_ids must contain one ID per outcome")
if family_ids.min().item() < 0:
raise ValueError("outcome family IDs must be non-negative")
self.num_event_families = int(family_ids.max().item()) + 1
if model_family_count is not None:
if int(model_family_count) < self.num_event_families:
raise ValueError("model_family_count excludes an outcome family")
self.num_event_families = int(model_family_count)
if self.event_conditioned_time_head and not self.num_event_families:
raise ValueError("event-conditioned time requires outcome_family_ids")
self.register_buffer("outcome_family_ids", family_ids, persistent=False)
self.register_buffer("clock_phase_token_ids", torch.empty(0, dtype=torch.long), persistent=False)
self.num_trajectory_horizons = int(num_trajectory_horizons)
self.token_embedding = nn.Embedding(num_tokens, hidden_dim, padding_idx=0)
self.kind_embedding = nn.Embedding(16, hidden_dim, padding_idx=0)
self.phase_embedding = (nn.Embedding(8, hidden_dim) if self.phase_memory else None)
self.value_projection = nn.Sequential(
nn.Linear(2, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim)
)
self.observation_projection = (nn.Sequential(
nn.Linear(3, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim, bias=False)
) if self.observation_intensity else None)
self.static_projection = nn.Sequential(
nn.Linear(num_static, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, hidden_dim)
)
self.history_projection = (nn.Sequential(
nn.Linear(self.num_event_families, hidden_dim), nn.GELU(),
nn.Linear(hidden_dim, hidden_dim, bias=False),
) if self.num_event_families and self.phase_memory else None)
self.time_encoding = ContinuousTimeEncoding(hidden_dim, enhanced_time_encoding)
layer = nn.TransformerEncoderLayer(
d_model=hidden_dim,
nhead=num_heads,
dim_feedforward=ffn_dim,
dropout=dropout,
activation="gelu",
batch_first=True,
norm_first=True,
bias=False,
)
self.encoder = nn.TransformerEncoder(layer, num_layers=num_layers,
norm=nn.LayerNorm(hidden_dim))
self.outcome_head = nn.Linear(hidden_dim, num_outcomes, bias=True)
self.family_head = (nn.Linear(hidden_dim, self.num_event_families, bias=True)
if self.num_event_families and self.use_family_head else None)
self.relative_time_bias = (nn.Sequential(
nn.Linear(2, max(16, num_heads * 2)), nn.GELU(),
nn.Linear(max(16, num_heads * 2), num_heads, bias=False),
) if self.relative_time_attention else None)
self.time_head = (nn.Linear(hidden_dim, 1, bias=True)
if self.decoupled_time_head and not self.lognormal_time_head else None)
time_output_dim = 2 * (self.num_event_families
if self.event_conditioned_time_head else 1)
self.time_distribution_head = (nn.Linear(hidden_dim, time_output_dim, bias=True)
if self.lognormal_time_head else None)
self.dual_hazard_head = (nn.Linear(
hidden_dim, self.num_outcomes * (self.fine_time_bins + self.long_time_bins), bias=True)
if self.dual_timescale_time_head else None)
self.tail_distribution_head = (nn.Linear(hidden_dim, self.num_outcomes * 2, bias=True)
if self.dual_timescale_time_head else None)
self.trajectory_head = nn.Linear(
hidden_dim, self.num_trajectory_horizons * self.num_outcomes, bias=True)
self.masked_token_bias = nn.Parameter(torch.zeros(num_tokens))
self.value_head = (nn.Sequential(
nn.Linear(hidden_dim, hidden_dim // 2), nn.GELU(),
nn.Linear(hidden_dim // 2, 1)) if self.value_reconstruction else None)
self.apply(self._init_weights)
# Initialise the aggregate rate to a clinically plausible sparse-event
# interval. A low starting rate prevents long event-free episodes from
# producing an explosive waiting-time likelihood at step zero.
interval = max(float(initial_event_interval_hours), 1.0 / 60.0)
if self.lognormal_time_head:
nn.init.zeros_(self.outcome_head.bias)
nn.init.zeros_(self.time_distribution_head.weight)
with torch.no_grad():
parameters = self.time_distribution_head.bias.view(-1, 2)
parameters[:, 0] = math.log(interval)
parameters[:, 1] = 0.0
elif self.decoupled_time_head:
nn.init.zeros_(self.outcome_head.bias)
nn.init.constant_(self.time_head.bias, math.log(1.0 / interval))
else:
nn.init.constant_(self.outcome_head.bias, math.log(1.0 / (interval * num_outcomes)))
@staticmethod
def _init_weights(module: nn.Module) -> None:
if isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.padding_idx is not None:
with torch.no_grad():
module.weight[module.padding_idx].zero_()
def forward(self, batch: Dict[str, torch.Tensor], *, causal: bool = True,
return_token_logits: bool = False,
masked_only: bool = False) -> EventMAOMAOOutput:
x = self.token_embedding(batch["token_id"])
x = x + self.kind_embedding(batch["token_kind"])
if not self.clock_phase_context:
# Remove the actual clock and phase-summary token signal for the
# corresponding module ablation. Time encodings remain available,
# so this does not accidentally ablate absolute/relative time.
drop = batch["token_kind"].eq(7)
if self.clock_phase_token_ids.numel():
drop = drop | torch.isin(batch["token_id"], self.clock_phase_token_ids)
x = x.masked_fill(drop[..., None], 0.0)
# Raw clinical values span several orders of magnitude. A signed-log
# transform prevents EBL/enzymes from dominating bounded severity and
# binary signals while preserving direction and ordering.
robust_value = torch.sign(batch["value"]) * torch.log1p(batch["value"].abs())
robust_value = robust_value.clamp(-12.0, 12.0)
value_features = torch.stack((robust_value, batch["has_value"]), dim=-1)
x = x + self.value_projection(value_features)
if self.observation_projection is not None and "observation_features" in batch:
x = x + self.observation_projection(batch["observation_features"])
x = x + self.time_encoding(batch["time_min"], batch["gap_min"])
x = x + self.static_projection(batch["static"])[:, None, :]
if self.phase_embedding is not None and "phase_id" in batch:
x = x + self.phase_embedding(batch["phase_id"])
if (self.history_projection is not None and "history_family_counts" in batch and
batch["history_family_counts"].shape[-1] == self.num_event_families):
x = x + self.history_projection(batch["history_family_counts"])[:, None, :]
length = x.shape[1]
causal_mask = self._attention_mask(batch["time_min"], causal)
padding = ~batch["attention_mask"].bool()
if causal_mask is not None and causal_mask.dtype.is_floating_point:
float_padding = torch.zeros_like(padding, dtype=causal_mask.dtype)
padding = float_padding.masked_fill(padding, -torch.inf)
encoded = self.encoder(x, mask=causal_mask, src_key_padding_mask=padding)
token_logits = (F.linear(encoded.float(), self.token_embedding.weight.float(),
self.masked_token_bias.float())
if return_token_logits else None)
value_prediction = (self.value_head(encoded).squeeze(-1).float()
if self.value_head is not None else None)
if masked_only:
return EventMAOMAOOutput(
logits=None, token_logits=token_logits,
value_prediction=value_prediction, hidden_state=encoded)
log_total_rate = (self.time_head(encoded).squeeze(-1).float()
if self.time_head is not None else None)
event_logits = self.outcome_head(encoded).float()
family_logits = (self.family_head(encoded).float()
if self.family_head is not None else None)
if family_logits is not None:
event_logits = event_logits + family_logits[..., self.outcome_family_ids]
time_mu = time_log_sigma = None
family_time_mu = family_time_log_sigma = None
fine_hazard_logits = long_hazard_logits = tail_mu = tail_log_sigma = None
if self.time_distribution_head is not None:
time_parameters = self.time_distribution_head(encoded).float()
if self.event_conditioned_time_head:
time_parameters = time_parameters.reshape(
*encoded.shape[:2], self.num_event_families, 2)
family_time_mu = time_parameters[..., 0]
family_time_log_sigma = time_parameters[..., 1].clamp(-3.0, 2.0)
if family_logits is None:
# In the family-head ablation the family-specific time
# parameters remain available, but no family classifier is
# allowed to route probability mass. Use a uniform prior
# so this ablation removes only the hierarchical family
# prediction pathway rather than silently changing the
# time-head tensor contract.
family_probability = torch.full_like(
family_time_mu, 1.0 / self.num_event_families)
else:
family_probability = torch.softmax(family_logits, dim=-1)
time_mu = (family_probability * family_time_mu).sum(-1)
time_log_sigma = (family_probability * family_time_log_sigma).sum(-1)
else:
time_mu = time_parameters[..., 0]
time_log_sigma = time_parameters[..., 1].clamp(-3.0, 2.0)
if self.dual_hazard_head is not None:
hazard = self.dual_hazard_head(encoded).float().reshape(
*encoded.shape[:2], self.num_outcomes,
self.fine_time_bins + self.long_time_bins)
fine_hazard_logits = hazard[..., :self.fine_time_bins]
long_hazard_logits = hazard[..., self.fine_time_bins:]
tail = self.tail_distribution_head(encoded).float().reshape(
*encoded.shape[:2], self.num_outcomes, 2)
tail_mu = tail[..., 0]
tail_log_sigma = tail[..., 1].clamp(-3.0, 2.0)
trajectory_logits = self.trajectory_head(encoded).float().reshape(
*encoded.shape[:2], self.num_trajectory_horizons, self.num_outcomes)
return EventMAOMAOOutput(
logits=event_logits, family_logits=family_logits,
log_total_rate=log_total_rate,
time_mu=time_mu, time_log_sigma=time_log_sigma,
family_time_mu=family_time_mu,
family_time_log_sigma=family_time_log_sigma,
fine_hazard_logits=fine_hazard_logits,
long_hazard_logits=long_hazard_logits,
tail_mu=tail_mu,
tail_log_sigma=tail_log_sigma,
trajectory_logits=trajectory_logits, token_logits=token_logits,
value_prediction=value_prediction, hidden_state=encoded)
def _attention_mask(self, time_min: torch.Tensor,
causal: bool) -> Optional[torch.Tensor]:
"""Build a temporal bias and optionally hide all concurrent siblings."""
batch_size, length = time_min.shape
if not causal and self.relative_time_bias is None and not self.context_segment_length:
return None
query_time = time_min[:, :, None]
key_time = time_min[:, None, :]
delta = query_time - key_time
mask = torch.zeros(
batch_size, self.num_heads, length, length,
device=time_min.device, dtype=torch.float32)
if self.relative_time_bias is not None:
scale = math.log1p(43200.0)
relative_features = torch.stack((
torch.sign(delta) * torch.log1p(delta.abs()) / scale,
(delta == 0).to(delta.dtype),
), dim=-1)
bias = self.relative_time_bias(relative_features).permute(0, 3, 1, 2)
mask = mask + bias.float()
if causal:
positions = torch.arange(length, device=time_min.device)
future = positions[None, :] > positions[:, None]
forbidden = future[None, :, :].expand(batch_size, -1, -1)
if self.same_time_block_causal:
same_time = query_time == key_time
diagonal = torch.eye(length, dtype=torch.bool, device=time_min.device)
forbidden = forbidden | (same_time & ~diagonal[None, :, :])
mask = mask.masked_fill(forbidden[:, None, :, :], -torch.inf)
if self.context_segment_length:
# Isolate contiguous segments at every encoder layer, including the
# masked auxiliary pass. A sliding per-layer band would leak older
# events through intermediate states and is not a strict length cap.
segment = torch.arange(length, device=time_min.device) // self.context_segment_length
separate = segment[:, None] != segment[None, :]
mask = mask.masked_fill(separate[None, None, :, :], -torch.inf)
return mask.reshape(batch_size * self.num_heads, length, length)
def event_time_loss(logits: torch.Tensor, target_set: torch.Tensor,
target_dt_hours: torch.Tensor, loss_mask: torch.Tensor,
time_mask: torch.Tensor | None = None, time_weight: float = 1.0,
max_wait_hours: float = 24.0 * 38,
stable_index: int = -1,
log_total_rate: torch.Tensor | None = None,
time_mu: torch.Tensor | None = None,
time_log_sigma: torch.Tensor | None = None,
trajectory_logits: torch.Tensor | None = None,
trajectory_target: torch.Tensor | None = None,
trajectory_mask: torch.Tensor | None = None,
trajectory_weight: float = 0.0,
class_weights: torch.Tensor | None = None,
family_logits: torch.Tensor | None = None,
outcome_family_ids: torch.Tensor | None = None,
family_weight: float = 0.0,
family_time_mu: torch.Tensor | None = None,
family_time_log_sigma: torch.Tensor | None = None,
fine_hazard_logits: torch.Tensor | None = None,
long_hazard_logits: torch.Tensor | None = None,
tail_mu: torch.Tensor | None = None,
tail_log_sigma: torch.Tensor | None = None) -> Dict[str, torch.Tensor]:
"""Event CE plus competing-exponential event/censor waiting-time NLL."""
event_valid = loss_mask.bool() & (target_set.sum(-1) > 0)
time_valid = event_valid if time_mask is None else time_mask.bool()
has_trajectory = (trajectory_logits is not None and trajectory_target is not None and
trajectory_mask is not None and trajectory_mask.any())
if not event_valid.any() and not time_valid.any() and not has_trajectory:
zero = logits.sum() * 0.0
if log_total_rate is not None:
zero = zero + log_total_rate.sum() * 0.0
return {"loss": zero, "event_loss": zero, "family_loss": zero,
"time_loss": zero,
"trajectory_loss": zero,
"event_accuracy": zero.detach(), "time_mae_hours": zero.detach(),
"nonstable_accuracy": zero.detach(),
"stable_prediction_fraction": zero.detach(),
"num_targets": event_valid.sum().detach(),
"num_time_targets": time_valid.sum().detach(),
"num_time_mae_targets": event_valid.sum().detach(),
"num_nonstable_targets": event_valid.sum().detach()}
zero = logits.sum() * 0.0
if log_total_rate is not None:
zero = zero + log_total_rate.sum() * 0.0
if event_valid.any():
selected_logits = logits[event_valid]
selected_targets = target_set[event_valid]
# Probability mass of the complete tied event set. Unlike averaging
# independent cross-entropies, this does not force simultaneous events
# to compete as if only one could be correct.
positive_logits = selected_logits.masked_fill(selected_targets <= 0, -torch.inf)
event_nll = torch.logsumexp(selected_logits, dim=-1) - torch.logsumexp(
positive_logits, dim=-1)
if class_weights is not None:
sample_weight = (selected_targets * class_weights).sum(-1) / selected_targets.sum(-1).clamp_min(1)
event_nll = event_nll * sample_weight
event_loss = event_nll.mean()
else:
selected_logits = logits.new_zeros((0, logits.shape[-1]))
selected_targets = target_set.new_zeros((0, target_set.shape[-1]))
event_loss = zero
family_loss = zero
if family_logits is not None and outcome_family_ids is not None and event_valid.any():
num_families = family_logits.shape[-1]
membership = F.one_hot(
outcome_family_ids.to(logits.device), num_classes=num_families).to(target_set.dtype)
family_targets = ((target_set[event_valid] @ membership) > 0).to(target_set.dtype)
selected_family_logits = family_logits[event_valid]
positive_family_logits = selected_family_logits.masked_fill(
family_targets <= 0, -torch.inf)
family_loss = (
torch.logsumexp(selected_family_logits, dim=-1)
- torch.logsumexp(positive_family_logits, dim=-1)
).mean()
dt = target_dt_hours[time_valid].float().clamp(
min=1.0 / 60.0, max=float(max_wait_hours))
is_event = event_valid[time_valid]
hazard_expected_wait = None
if (fine_hazard_logits is not None and long_hazard_logits is not None and
tail_mu is not None and tail_log_sigma is not None):
# Event-specific dual-timescale hazard. Fine bins cover 0-120 min in
# five-minute intervals; long bins cover 2 h onward in 30-minute
# intervals. The continuous tail models the residual wait after the
# last long bin (24 h with the default 44-bin configuration).
fine_width = 5.0 / 60.0
long_width = 30.0 / 60.0
fine_edges = fine_width * torch.arange(
fine_hazard_logits.shape[-1] + 1, device=dt.device, dtype=dt.dtype)
long_edges = 2.0 + long_width * torch.arange(
long_hazard_logits.shape[-1] + 1, device=dt.device, dtype=dt.dtype)
long_end = float(long_edges[-1])
targets_for_time = target_set[time_valid].float()
class_count = targets_for_time.sum(-1).clamp_min(1.0)
fine_logits = fine_hazard_logits[time_valid].float()
long_logits = long_hazard_logits[time_valid].float()
fine_bin = torch.bucketize(dt, fine_edges[1:-1]).clamp_max(fine_logits.shape[-1] - 1)
long_bin = torch.bucketize(dt, long_edges[1:-1]).clamp_max(long_logits.shape[-1] - 1)
fine_log_survival = F.logsigmoid(-fine_logits)
fine_cumulative = fine_log_survival.cumsum(-1)
fine_index = fine_bin[:, None, None].expand(-1, fine_logits.shape[1], 1)
fine_log_hazard = F.logsigmoid(torch.gather(fine_logits, -1, fine_index).squeeze(-1))
fine_previous = torch.gather(
fine_cumulative, -1, (fine_bin - 1).clamp_min(0)[:, None, None]
.expand(-1, fine_logits.shape[1], 1)).squeeze(-1)
fine_previous = torch.where(fine_bin[:, None] > 0, fine_previous,
torch.zeros_like(fine_previous))
fine_event_nll = -((fine_log_hazard + fine_previous) * targets_for_time).sum(-1) / class_count
long_log_survival = F.logsigmoid(-long_logits)
long_cumulative = long_log_survival.cumsum(-1)
long_index = long_bin[:, None, None].expand(-1, long_logits.shape[1], 1)
long_log_hazard = F.logsigmoid(torch.gather(long_logits, -1, long_index).squeeze(-1))
long_previous = torch.gather(
long_cumulative, -1, (long_bin - 1).clamp_min(0)[:, None, None]
.expand(-1, long_logits.shape[1], 1)).squeeze(-1)
long_previous = torch.where(long_bin[:, None] > 0, long_previous,
torch.zeros_like(long_previous))
fine_to_long = fine_log_survival.sum(-1)
long_event_nll = -((long_log_hazard + long_previous + fine_to_long) * targets_for_time).sum(-1) / class_count
tail_dt = (dt - long_end).clamp_min(1.0 / 60.0)
tail_sigma = tail_log_sigma[time_valid].float().exp()
tail_mu_selected = tail_mu[time_valid].float()
tail_z = (tail_dt[:, None].log() - tail_mu_selected) / tail_sigma
tail_event_nll = ((0.5 * tail_z.square() + tail_log_sigma[time_valid].float() +
tail_dt[:, None].log() + 0.5 * math.log(2 * math.pi)) *
targets_for_time).sum(-1) / class_count
long_to_tail = long_log_survival.sum(-1)
tail_log_survival = torch.log((0.5 * torch.erfc(tail_z / math.sqrt(2.0))).clamp_min(1e-7))
tail_event_nll = tail_event_nll - ((long_to_tail + fine_to_long + tail_log_survival) *
targets_for_time).sum(-1) / class_count
fine_rows = dt < 2.0
long_rows = (dt >= 2.0) & (dt < long_end)
tail_rows = dt >= long_end
# Censored rows contribute survival-only terms across all event types.
censor = ~is_event
censor_nll = torch.zeros_like(dt)
censor_nll = torch.where(fine_rows, -fine_previous.mean(-1), censor_nll)
censor_nll = torch.where(long_rows, -(fine_to_long.mean(-1) + long_previous.mean(-1)), censor_nll)
censor_nll = torch.where(tail_rows, -(fine_to_long.mean(-1) + long_to_tail.mean(-1) + tail_log_survival.mean(-1)), censor_nll)
time_nll = torch.where(is_event & fine_rows, fine_event_nll,
torch.where(is_event & long_rows, long_event_nll,
torch.where(is_event & tail_rows, tail_event_nll, censor_nll)))
time_loss = time_nll.mean() if time_nll.numel() else zero
log_rate = None
mu = dt.new_zeros(dt.shape)
sigma = dt.new_ones(dt.shape)
# Decode an event-specific expected waiting time for diagnostics. It
# is not used to replace the hazard likelihood: each event keeps its
# own discrete survival curve and continuous tail.
fine_probability = torch.sigmoid(fine_logits)
fine_previous_survival = torch.exp(torch.cat((
torch.zeros_like(fine_log_survival[..., :1]),
fine_log_survival[..., :-1].cumsum(-1)), dim=-1))
fine_midpoints = (fine_edges[:-1] + fine_edges[1:]) / 2.0
fine_mean = (fine_previous_survival * fine_probability * fine_midpoints).sum(-1)
survival_after_fine = torch.exp(fine_log_survival.sum(-1))
long_probability = torch.sigmoid(long_logits)
long_previous_survival = torch.exp(torch.cat((
torch.zeros_like(long_log_survival[..., :1]),
long_log_survival[..., :-1].cumsum(-1)), dim=-1))
long_midpoints = (long_edges[:-1] + long_edges[1:]) / 2.0
long_mean = (long_previous_survival * long_probability * long_midpoints).sum(-1)
survival_after_long = torch.exp(long_log_survival.sum(-1))
tail_mean = 6.0 + torch.exp(tail_mu[time_valid].float() +
0.5 * tail_log_sigma[time_valid].float().exp().square())
expected_by_event = fine_mean + survival_after_fine * (
long_mean + survival_after_long * tail_mean)
hazard_expected_wait = torch.where(
is_event,
(expected_by_event * targets_for_time).sum(-1) / class_count,
expected_by_event.mean(-1),
).clamp_max(max_wait_hours)
elif time_mu is not None and time_log_sigma is not None:
mu = time_mu[time_valid].float()
log_sigma = time_log_sigma[time_valid].float().clamp(-3.0, 2.0)
if (family_time_mu is not None and family_time_log_sigma is not None and
outcome_family_ids is not None and is_event.any()):
membership = F.one_hot(
outcome_family_ids.to(logits.device),
num_classes=family_time_mu.shape[-1]).to(target_set.dtype)
time_family_targets = (target_set[time_valid] @ membership).clamp_max(1.0)
weights = time_family_targets / time_family_targets.sum(-1, keepdim=True).clamp_min(1.0)
conditional_mu = (family_time_mu[time_valid].float() * weights).sum(-1)
conditional_log_sigma = (
family_time_log_sigma[time_valid].float() * weights).sum(-1).clamp(-3.0, 2.0)
mu = torch.where(is_event, conditional_mu, mu)
log_sigma = torch.where(is_event, conditional_log_sigma, log_sigma)
sigma = log_sigma.exp()
log_dt = dt.log()
z = (log_dt - mu) / sigma
event_time_nll = (0.5 * z.square() + log_sigma + log_dt +
0.5 * math.log(2 * math.pi))
survival = (0.5 * torch.erfc(z / math.sqrt(2.0))).clamp_min(1e-7)
time_nll = torch.where(is_event, event_time_nll, -survival.log())
log_rate = None
elif log_total_rate is None:
time_logits = logits[time_valid]
log_rate = torch.logsumexp(time_logits, dim=-1)
else:
log_rate = log_total_rate[time_valid]
if log_rate is not None:
log_rate = log_rate.clamp(min=-16.0, max=8.0)
time_nll = torch.exp(log_rate) * dt - log_rate * is_event.float()
time_loss = time_nll.mean() if time_nll.numel() else zero
trajectory_loss = zero
if (trajectory_logits is not None and trajectory_target is not None and
trajectory_mask is not None and trajectory_mask.any()):
valid = trajectory_mask.bool().unsqueeze(-1).expand_as(trajectory_target)
raw = F.binary_cross_entropy_with_logits(
trajectory_logits[valid], trajectory_target[valid], reduction="none")
probability = torch.sigmoid(trajectory_logits[valid])
target = trajectory_target[valid]
pt = torch.where(target > 0, probability, 1.0 - probability)
alpha = torch.where(target > 0, 0.75, 0.25)
trajectory_loss = (alpha * (1.0 - pt).square() * raw).mean()
total = (event_loss + float(family_weight) * family_loss +
float(time_weight) * time_loss +
float(trajectory_weight) * trajectory_loss)
top = selected_logits.argmax(-1) if len(selected_logits) else torch.empty(
0, dtype=torch.long, device=logits.device)
hit = (selected_targets.gather(1, top[:, None]).squeeze(1) > 0
if len(selected_logits) else torch.empty(0, dtype=torch.bool, device=logits.device))
if stable_index >= 0:
nonstable = selected_targets.sum(-1).bool() & ~selected_targets[:, stable_index].bool()
stable_fraction = (top == stable_index).float().mean() if len(top) else zero.detach()
else:
nonstable = torch.ones(len(selected_targets), dtype=torch.bool, device=logits.device)
stable_fraction = zero.detach()
nonstable_accuracy = hit[nonstable].float().mean() if nonstable.any() else hit.float().sum() * 0.0
if hazard_expected_wait is not None:
expected_wait = hazard_expected_wait
elif time_mu is not None and time_log_sigma is not None:
expected_wait = torch.exp(mu + 0.5 * sigma.square()).clamp_max(max_wait_hours)
else:
expected_wait = torch.exp(-log_rate)
event_wait_mae = ((expected_wait[is_event] - dt[is_event]).abs().mean()
if is_event.any() else zero.detach())
return {
"loss": total,
"event_loss": event_loss,
"family_loss": family_loss,
"time_loss": time_loss,
"trajectory_loss": trajectory_loss,
"event_accuracy": hit.float().mean().detach() if len(hit) else zero.detach(),
"nonstable_accuracy": nonstable_accuracy.detach(),
"stable_prediction_fraction": stable_fraction.detach(),
"time_mae_hours": event_wait_mae.detach(),
"num_targets": event_valid.sum().detach(),
"num_time_targets": time_valid.sum().detach(),
"num_time_mae_targets": is_event.sum().detach(),
"num_nonstable_targets": nonstable.sum().detach(),
}
def masked_event_loss(token_logits: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
"""Cross entropy over positions selected for bidirectional event masking."""
valid = labels >= 0
if not valid.any():
return token_logits.sum() * 0.0
return F.cross_entropy(token_logits[valid], labels[valid])
def masked_value_loss(value_prediction: torch.Tensor, labels: torch.Tensor,
mask: torch.Tensor) -> torch.Tensor:
"""Robustly reconstruct signed-log clinical values hidden from the encoder."""
if not mask.any():
return value_prediction.sum() * 0.0
target = torch.sign(labels[mask]) * torch.log1p(labels[mask].abs())
target = target.clamp(-12.0, 12.0)
return F.smooth_l1_loss(value_prediction[mask], target)