| """VLAlert-X v2 Phase 4 β Policy Head with dual-stream + danger conditioning. |
| |
| Inputs (per tick): |
| β’ POLICY_POSITION[B, 8, 2560] β decision-time register from cache |
| β’ perception_summary[B, 4, 512] β from frozen DangerHead (PMA pooled) |
| β’ danger_per_frame[B, 8] β from frozen DangerHead (continuous) |
| β’ prev_action[B] long β previous tick's action (0/1/2 or BOS=3) |
| |
| Architecture: |
| POLICY_POSITION ββ> GRU(2 layers, h=512) ββ> last_state [B, 512] |
| β |
| perception_summary ββ> proj [B, 256] ββββββββββββββ€ |
| βΌ |
| [last_state, percep, danger, prev_act] ββ MLP ββ [B, 3] |
| |
| Loss: CE with class-balanced weights + label smoothing + entropy reg. |
| The frozen DangerHead provides perception_summary and danger_per_frame as |
| pre-computed features (just forward DangerHead once on cached |
| belief_content, then save). Policy Head's gradient does not flow into |
| DangerHead. |
| """ |
| from __future__ import annotations |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
|
|
| class PolicyHeadV2(nn.Module): |
| def __init__(self, |
| policy_dim: int = 2560, |
| perception_dim_per_query: int = 512, |
| k_queries: int = 4, |
| prev_act_emb: int = 16, |
| gru_hidden: int = 512, |
| n_classes: int = 3, |
| dropout: float = 0.2, |
| with_anticipation: bool = False): |
| super().__init__() |
| |
| self.gru = nn.GRU(policy_dim, gru_hidden, num_layers=2, |
| batch_first=True, dropout=dropout) |
| |
| self.perception_proj = nn.Sequential( |
| nn.Linear(perception_dim_per_query * k_queries, 256), |
| nn.GELU(), |
| nn.LayerNorm(256), |
| nn.Dropout(dropout), |
| ) |
| |
| self.action_emb = nn.Embedding(n_classes + 1, prev_act_emb) |
|
|
| |
| |
| fuse_in = gru_hidden + 256 + 8 + prev_act_emb |
| self.fuse_pre = nn.Sequential( |
| nn.Linear(fuse_in, 256), nn.GELU(), |
| nn.Dropout(dropout), |
| ) |
| self.cls_head = nn.Linear(256, n_classes) |
|
|
| |
| |
| |
| self.with_anticipation = with_anticipation |
| if with_anticipation: |
| self.anticipation_head = nn.Linear(256, 1) |
|
|
| |
| @property |
| def fuse(self) -> nn.Module: |
| return nn.Sequential(self.fuse_pre, self.cls_head) |
|
|
| def forward(self, |
| policy_position: torch.Tensor, |
| perception_summary: torch.Tensor, |
| danger_per_frame: torch.Tensor, |
| prev_action: torch.Tensor, |
| valid_frames: torch.Tensor | None = None, |
| return_aux: bool = False, |
| ): |
| |
| |
| |
| |
| |
| if valid_frames is not None: |
| mask = valid_frames.unsqueeze(-1).to(policy_position.dtype) |
| policy_position = policy_position * mask |
| gru_out, _ = self.gru(policy_position) |
| |
| |
| |
| |
| if valid_frames is not None: |
| T = valid_frames.shape[1] |
| idx_t = torch.arange(T, device=valid_frames.device).expand_as(valid_frames) |
| masked = torch.where(valid_frames, idx_t, torch.full_like(idx_t, -1)) |
| last_idx = masked.max(dim=1).values.clamp(min=0) |
| last_state = gru_out[torch.arange(gru_out.size(0)), last_idx] |
| else: |
| last_state = gru_out[:, -1] |
| percep = self.perception_proj(perception_summary.flatten(1)) |
| prev = self.action_emb(prev_action) |
| fused = torch.cat([last_state, percep, danger_per_frame, prev], dim=-1) |
| h = self.fuse_pre(fused) |
| logits = self.cls_head(h) |
| if return_aux and self.with_anticipation: |
| antic_logit = self.anticipation_head(h).squeeze(-1) |
| return logits, antic_logit |
| return logits |
|
|
|
|
| def policy_loss(logits: torch.Tensor, |
| targets: torch.Tensor, |
| class_weights: torch.Tensor | None = None, |
| label_smoothing: float = 0.05, |
| entropy_reg: float = 0.02, |
| use_focal: bool = False, |
| focal_gamma: float = 2.0, |
| focal_alpha: torch.Tensor | None = None, |
| use_ordinal: bool = False, |
| ordinal_margin: float = 1.0, |
| ordinal_lax: float = 0.5, |
| ordinal_weight: float = 0.5, |
| antic_logit: torch.Tensor | None = None, |
| antic_target: torch.Tensor | None = None, |
| antic_weight: float = 0.3, |
| prev_p_alert: torch.Tensor | None = None, |
| cur_p_alert: torch.Tensor | None = None, |
| temporal_weight: float = 0.1) -> dict: |
| """Composite loss for OBSERVE-encouraging supervised training. |
| |
| Components (each optional, controlled by flag): |
| - Base CE (or Focal CE) with class weights + label smoothing |
| - Entropy regulariser (keep policy soft for RL warm-start) |
| - Ordinal margin: penalise "skip OBSERVE" predictions |
| - Anticipation aux: BCE on "next tick is ALERT" logit |
| - Temporal consistency: penalise negative P(ALERT) jumps in consecutive ticks |
| |
| Args: |
| use_focal: if True replace CE with focal-CE (Ξ³=focal_gamma). |
| focal_alpha: per-class weight tensor [3]. SILENT/OBSERVE/ALERT |
| suggested (1.0, 2.5, 1.5). |
| use_ordinal: if True add ordinal-margin loss enforcing logit |
| SILENT < OBSERVE < ALERT ordering. |
| ordinal_margin: required gap between predicted class and the *correct* |
| neighbour (e.g. OBSERVE must beat SILENT by margin). |
| ordinal_lax: allowed slack for "non-correct neighbour" (e.g. OBSERVE |
| can be β€ ALERT but not by more than `ordinal_lax`). |
| antic_logit: [B] anticipation head logits (None to skip). |
| antic_target: [B] {0,1} target: 1 if next-tick is ALERT-class. |
| prev_p_alert: [B] P(ALERT) of previous tick in the same video |
| (None to skip temporal consistency). |
| cur_p_alert: [B] P(ALERT) of current tick. |
| temporal_weight: weight on temporal-consistency penalty. |
| """ |
| log_p = F.log_softmax(logits, dim=-1) |
| probs = log_p.exp() |
|
|
| |
| if use_focal: |
| |
| p_y = probs.gather(1, targets.unsqueeze(1)).squeeze(1).clamp(min=1e-8) |
| focal_w = (1.0 - p_y).pow(focal_gamma) |
| log_p_y = log_p.gather(1, targets.unsqueeze(1)).squeeze(1) |
| if focal_alpha is not None: |
| a = focal_alpha.to(logits.device).gather(0, targets) |
| ce_per = -a * focal_w * log_p_y |
| else: |
| ce_per = -focal_w * log_p_y |
| |
| if class_weights is not None: |
| cw = class_weights.to(logits.device).gather(0, targets) |
| ce_per = ce_per * cw |
| ce = ce_per.mean() |
| else: |
| ce = F.cross_entropy(logits, targets, weight=class_weights, |
| label_smoothing=label_smoothing) |
|
|
| |
| |
| ord_loss = logits.new_zeros(()) |
| if use_ordinal: |
| l_sil = logits[:, 0] |
| l_obs = logits[:, 1] |
| l_alr = logits[:, 2] |
| sil_mask = (targets == 0) |
| obs_mask = (targets == 1) |
| alr_mask = (targets == 2) |
|
|
| |
| if sil_mask.any(): |
| ord_loss = ord_loss + F.relu( |
| (l_obs[sil_mask] - l_sil[sil_mask]) + ordinal_margin |
| ).mean() |
| ord_loss = ord_loss + F.relu( |
| (l_alr[sil_mask] - l_obs[sil_mask]) + ordinal_lax |
| ).mean() * 0.5 |
| |
| if obs_mask.any(): |
| ord_loss = ord_loss + F.relu( |
| (l_sil[obs_mask] - l_obs[obs_mask]) + ordinal_margin |
| ).mean() |
| ord_loss = ord_loss + F.relu( |
| (l_alr[obs_mask] - l_obs[obs_mask]) - ordinal_lax |
| ).clamp(min=0).mean() * 0.5 |
| |
| |
| if alr_mask.any(): |
| ord_loss = ord_loss + F.relu( |
| (l_obs[alr_mask] - l_alr[alr_mask]) + ordinal_margin |
| ).mean() |
| ord_loss = ord_loss + F.relu( |
| (l_sil[alr_mask] - l_obs[alr_mask]) + ordinal_lax |
| ).mean() |
|
|
| |
| antic_loss = logits.new_zeros(()) |
| if antic_logit is not None and antic_target is not None: |
| antic_loss = F.binary_cross_entropy_with_logits( |
| antic_logit, antic_target.float() |
| ) |
|
|
| |
| temp_loss = logits.new_zeros(()) |
| if prev_p_alert is not None and cur_p_alert is not None: |
| delta = cur_p_alert - prev_p_alert |
| |
| |
| temp_loss = (F.relu(-delta).pow(2).mean() |
| + F.relu(delta - 0.5).pow(2).mean()) |
|
|
| |
| entropy = -(probs * (probs + 1e-9).log()).sum(dim=-1).mean() |
|
|
| total = (ce |
| + (ordinal_weight if use_ordinal else 0.0) * ord_loss |
| + (antic_weight if antic_logit is not None else 0.0) * antic_loss |
| + temporal_weight * temp_loss |
| - entropy_reg * entropy) |
|
|
| return { |
| "loss": total, |
| "ce": ce.detach(), |
| "ordinal": ord_loss.detach(), |
| "antic": antic_loss.detach(), |
| "temporal": temp_loss.detach(), |
| "entropy": entropy.detach(), |
| } |
|
|
|
|
| |
| |
| FOCAL_ALPHA_9K = torch.tensor([1.0, 2.5, 1.5], dtype=torch.float32) |
|
|