Download model.py from luan0519/MAOMAO: direct link, hf CLI and curl.
- Browser
- Download file 38.6 kB
-
https://huggingface.co/luan0519/MAOMAO/resolve/main/model.py
- Command line
-
hf download hf://luan0519/MAOMAO/model.py
-
curl -L -o model.py https://huggingface.co/luan0519/MAOMAO/resolve/main/model.py
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 | |
| 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))) | |
| 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) | |