| """VLAlert-X v2 Phase 3 — Danger Head. |
| |
| Continuous per-frame and clip-level risk regressor on BELIEF_CONTENT |
| features (the perception/risk-cue register from Phase 2 cache). |
| |
| Supervision: TTA-derived continuous danger ∈ [0, 1] |
| danger[f] = sigmoid(4 * (L_alert - tta_f) / L_alert) for tta in (0, 5] |
| danger[f] = 0.05 (floor) for SILENT clips |
| danger[f] = 1.0 for post-event frames |
| |
| This is an interpretable, threshold-free risk score that the downstream |
| Policy Head (Phase 4) consumes as an input feature. It also exposes a |
| clip-level scalar useful as a fallback alert score (e.g., for ablations |
| where Policy Head is removed). |
| |
| Architecture: |
| BELIEF_CONTENT [B, 8, 10240] |
| │ |
| ├──> per-frame MLP ──> [B, 8] sigmoid (per-frame danger) |
| │ |
| └──> MultiQueryPMA (K=4) ──> [B, 4, 512] (perception_summary) |
| │ |
| └──> clip MLP ──> [B] sigmoid |
| (clip danger) |
| |
| The `perception_summary` is returned alongside heads so the Policy Head |
| (Phase 4) can re-use it without re-running the PMA aggregator. |
| """ |
| from __future__ import annotations |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
|
|
| class MultiQueryPMAAggregator(nn.Module): |
| """Multi-query Pooling by Multi-head Attention (PMA, Lee et al. 2019). |
| |
| K learnable query vectors attend to the per-frame tokens to produce |
| K summary vectors. Simpler and more parameter-efficient than a full |
| Transformer encoder for fixed-length pooling. |
| """ |
| def __init__(self, in_dim: int, k_queries: int = 4, out_dim: int = 512, |
| dropout: float = 0.1): |
| super().__init__() |
| self.k = k_queries |
| self.out_dim = out_dim |
| |
| self.in_proj = nn.Linear(in_dim, out_dim) |
| |
| self.queries = nn.Parameter(torch.randn(k_queries, out_dim) * 0.02) |
| self.attn = nn.MultiheadAttention(out_dim, num_heads=4, |
| dropout=dropout, batch_first=True) |
| self.norm = nn.LayerNorm(out_dim) |
| self.ffn = nn.Sequential( |
| nn.Linear(out_dim, out_dim * 2), nn.GELU(), |
| nn.Dropout(dropout), nn.Linear(out_dim * 2, out_dim)) |
| self.norm2 = nn.LayerNorm(out_dim) |
|
|
| def forward(self, x: torch.Tensor, |
| mask: torch.Tensor | None = None) -> torch.Tensor: |
| """ |
| x: [B, T, in_dim] — per-frame features |
| mask: [B, T] — True = valid frame |
| returns: [B, K, out_dim] |
| """ |
| B = x.size(0) |
| h = self.in_proj(x) |
| q = self.queries.unsqueeze(0).expand(B, -1, -1) |
| key_padding_mask = None |
| if mask is not None: |
| key_padding_mask = ~mask |
| attn_out, _ = self.attn(q, h, h, |
| key_padding_mask=key_padding_mask) |
| h2 = self.norm(q + attn_out) |
| h3 = self.norm2(h2 + self.ffn(h2)) |
| return h3 |
|
|
|
|
| class DangerHead(nn.Module): |
| """Continuous risk regressor on BELIEF_CONTENT features. |
| |
| Args: |
| in_dim: hidden dim of BELIEF_CONTENT (default 10240 for L4 concat) |
| hidden: internal width |
| k_queries: number of PMA queries |
| dropout: dropout rate |
| n_hazards: if > 0, also emit a k-way hazard classification logit |
| over the AdaptiveWindow 8-way taxonomy (Phase G.0). |
| New tensor in output dict: 'hazard_logits' [B, n_hazards]. |
| Backward-compatible: defaults to 0 → no hazard head. |
| """ |
| def __init__(self, in_dim: int = 10240, hidden: int = 512, |
| k_queries: int = 4, dropout: float = 0.2, |
| n_hazards: int = 0): |
| super().__init__() |
| self.n_hazards = n_hazards |
| |
| self.frame_proj = nn.Sequential( |
| nn.Linear(in_dim, hidden), nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(hidden, hidden // 2), nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(hidden // 2, 1)) |
|
|
| |
| self.pma = MultiQueryPMAAggregator( |
| in_dim=in_dim, k_queries=k_queries, |
| out_dim=hidden, dropout=dropout) |
|
|
| |
| self.clip_mlp = nn.Sequential( |
| nn.Linear(hidden * k_queries, hidden), nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(hidden, 1)) |
|
|
| |
| if n_hazards > 0: |
| self.hazard_head = nn.Sequential( |
| nn.Linear(hidden * k_queries, hidden), nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(hidden, n_hazards)) |
|
|
| def forward(self, belief_content: torch.Tensor, |
| valid_frames: torch.Tensor | None = None) -> dict: |
| """ |
| belief_content: [B, 8, in_dim] |
| valid_frames: [B, 8] bool (True = valid) |
| |
| Returns: |
| { |
| "per_frame": [B, 8] sigmoid prob |
| "per_frame_logits": [B, 8] |
| "clip": [B] sigmoid prob |
| "clip_logit": [B] |
| "perception_summary": [B, K, hidden] for downstream re-use |
| "hazard_logits": [B, n_hazards] (only if n_hazards > 0) |
| } |
| """ |
| |
| per_frame_logits = self.frame_proj(belief_content).squeeze(-1) |
| per_frame = torch.sigmoid(per_frame_logits) |
|
|
| |
| pooled = self.pma(belief_content, mask=valid_frames) |
| clip_logit = self.clip_mlp(pooled.flatten(1)).squeeze(-1) |
| clip = torch.sigmoid(clip_logit) |
|
|
| out = { |
| "per_frame": per_frame, |
| "per_frame_logits": per_frame_logits, |
| "clip": clip, |
| "clip_logit": clip_logit, |
| "perception_summary": pooled, |
| } |
| if self.n_hazards > 0: |
| out["hazard_logits"] = self.hazard_head(pooled.flatten(1)) |
| return out |
|
|
|
|
| def danger_loss(out: dict, |
| danger_per_frame: torch.Tensor, |
| valid_frames: torch.Tensor | None = None, |
| w_clip: float = 0.5) -> dict: |
| """BCE on per-frame + BCE on clip-level (clip target = max over frames). |
| |
| out: output dict of DangerHead.forward |
| danger_per_frame: [B, 8] continuous targets in [0, 1] |
| valid_frames: [B, 8] bool |
| Returns dict with 'loss', 'frame_loss', 'clip_loss'. |
| """ |
| pf = out["per_frame_logits"] |
| if valid_frames is not None: |
| frame_target = danger_per_frame.clamp(0.0, 1.0) |
| |
| loss_per = F.binary_cross_entropy_with_logits( |
| pf, frame_target, reduction="none") |
| loss_per = loss_per * valid_frames.float() |
| denom = valid_frames.float().sum().clamp(min=1.0) |
| frame_loss = loss_per.sum() / denom |
| else: |
| frame_loss = F.binary_cross_entropy_with_logits( |
| pf, danger_per_frame.clamp(0.0, 1.0)) |
|
|
| clip_target = danger_per_frame.max(dim=1).values.clamp(0.0, 1.0) |
| clip_loss = F.binary_cross_entropy_with_logits(out["clip_logit"], clip_target) |
|
|
| return { |
| "loss": frame_loss + w_clip * clip_loss, |
| "frame_loss": frame_loss.detach(), |
| "clip_loss": clip_loss.detach(), |
| } |
|
|