""" 模型组件:TTA头、策略头 """ import math import torch import torch.nn as nn import torch.nn.functional as F class TTAHead(nn.Module): """ TTA回归头 输入: belief向量 [B, hidden_dim] 输出: (tta_mean, tta_logvar) """ def __init__(self, hidden_dim, intermediate_dim=512): super().__init__() self.hidden_dim = hidden_dim self.intermediate_dim = intermediate_dim self.net = nn.Sequential( nn.Linear(hidden_dim, intermediate_dim), nn.ReLU(), nn.Dropout(0.1), nn.Linear(intermediate_dim, 128), nn.ReLU(), nn.Dropout(0.1), nn.Linear(128, 2) # mean, log_var ) def forward(self, hidden_state): """ Args: hidden_state: [B, hidden_dim] Returns: tta_mean: [B] tta_logvar: [B] """ output = self.net(hidden_state) tta_mean = output[:, 0] tta_logvar = output[:, 1] return tta_mean, tta_logvar class PolicyHead(nn.Module): """ 策略头(DPO阶段训练) 输入: belief向量 + TTA统计 + 历史编码 输出: 动作logits [B, 3] """ def __init__(self, hidden_dim, num_actions=3, dropout=0.2): super().__init__() self.hidden_dim = hidden_dim self.num_actions = num_actions # 历史动作编码器 self.action_embedding = nn.Embedding(num_actions, 16) # 策略网络 # 输入: hidden_dim + 2(tta_mean, tta_var) + 16(history) input_dim = hidden_dim + 2 + 16 self.net = nn.Sequential( nn.Linear(input_dim, 512), nn.ReLU(), nn.Dropout(dropout), nn.Linear(512, 256), nn.ReLU(), nn.Dropout(dropout), nn.Linear(256, num_actions) ) def forward(self, hidden_state, tta_mean, tta_var, prev_action): """ Args: hidden_state: [B, hidden_dim] tta_mean: [B] tta_var: [B] prev_action: [B] (0=silent, 1=observe, 2=alert) Returns: action_logits: [B, 3] """ # 编码历史动作 action_emb = self.action_embedding(prev_action) # [B, 16] # 拼接所有特征 features = torch.cat([ hidden_state, tta_mean.unsqueeze(-1), tta_var.unsqueeze(-1), action_emb ], dim=-1) logits = self.net(features) return logits class EvidentialPolicyHead(nn.Module): """ Evidential PolicyHead — outputs Dirichlet concentration parameters α. Instead of softmax logits, predicts evidence e ≥ 0 for each class, then α = e + 1 forms a Dirichlet distribution Dir(α). From α we derive: - expected probability: p = α / S where S = Σα - epistemic uncertainty: u = K / S (K = num_actions) At inference, high u → default to OBSERVE (conservative). """ def __init__(self, hidden_dim, num_actions=3, dropout=0.2): super().__init__() self.hidden_dim = hidden_dim self.num_actions = num_actions self.action_embedding = nn.Embedding(num_actions, 16) input_dim = hidden_dim + 2 + 16 self.net = nn.Sequential( nn.Linear(input_dim, 512), nn.GELU(), nn.Dropout(dropout), nn.Linear(512, 256), nn.GELU(), nn.Dropout(dropout), nn.Linear(256, num_actions), ) nn.init.zeros_(self.net[-1].weight) nn.init.constant_(self.net[-1].bias, 1.0) def forward(self, hidden_state, tta_mean, tta_var, prev_action): action_emb = self.action_embedding(prev_action) features = torch.cat([ hidden_state, tta_mean.unsqueeze(-1), tta_var.unsqueeze(-1), action_emb, ], dim=-1) out = self.net(features) evidence = F.softplus(out) alpha = evidence + 1.0 return alpha def predict(self, alpha): S = alpha.sum(dim=-1, keepdim=True) p = alpha / S u = float(self.num_actions) / S.squeeze(-1) return p, u class BinaryCollisionHead(nn.Module): """Binary collision classifier for Nexar-style detection. Bypasses 3-class softmax bottleneck by directly predicting P(collision).""" def __init__(self, hidden_dim=2048, dropout=0.2): super().__init__() self.net = nn.Sequential( nn.Linear(hidden_dim + 2, 512), nn.GELU(), nn.Dropout(dropout), nn.Linear(512, 256), nn.GELU(), nn.Dropout(dropout), nn.Linear(256, 1), ) def forward(self, hidden_state, tta_mean, tta_var): x = torch.cat([hidden_state, tta_mean.unsqueeze(-1), tta_var.unsqueeze(-1)], dim=-1) return self.net(x).squeeze(-1) class BinaryTemporalHead(nn.Module): """Per-window binary collision scorer with max aggregation (BADAS-style).""" def __init__(self, hidden_dim=2048, proj_dim=256, dropout=0.2): super().__init__() self.proj = nn.Linear(hidden_dim, proj_dim) self.scorer = nn.Sequential( nn.GELU(), nn.Dropout(dropout), nn.Linear(proj_dim + 2, 128), nn.GELU(), nn.Dropout(dropout), nn.Linear(128, 1), ) def forward(self, beliefs_frame, tta_mean_seq=None, tta_var_seq=None, valid_mask=None): """ beliefs_frame: [B, T, D] tta_mean_seq: [B, T] or None tta_var_seq: [B, T] or None valid_mask: [B, T] or None Returns: clip_score [B], per_window_score [B, T] """ B, T, D = beliefs_frame.shape h = self.proj(beliefs_frame) if tta_mean_seq is not None: h = torch.cat([h, tta_mean_seq.unsqueeze(-1), tta_var_seq.unsqueeze(-1)], dim=-1) else: h = torch.cat([h, torch.zeros(B, T, 2, device=h.device)], dim=-1) per_window = self.scorer(h).squeeze(-1) if valid_mask is not None: per_window = per_window.masked_fill(~valid_mask, -1e9) clip_score = per_window.max(dim=1).values return clip_score, per_window class HierarchicalPolicyHead(nn.Module): """ Hierarchical Risk Assessment Head — replaces 3-class softmax with two independent binary classifiers to break probability competition. Motivation (empirical + theoretical): - 3-class softmax locks AP at 0.24 because P(ALERT) + P(OBSERVE) + P(SILENT) = 1, so high P(OBSERVE) necessarily suppresses P(ALERT). - Binary ablation (OBSERVE→ALERT merge) achieves AP=0.888, proving features are sufficient — the bottleneck is the output parameterisation. - Binary Relevance decomposition (Tsoumakas & Katakis, 2007; Read et al., 2011) avoids label competition inherent in shared-simplex classifiers. - Hierarchical decision-making aligns with cascaded safety assessment in AD (Norden et al., 2025; Pjetri et al., ECCV-W 2025). Architecture: SharedTrunk: (belief ⊕ tta_mean ⊕ tta_var ⊕ action_emb) → 512 → 256 AlertHead: 256 → 1 (sigmoid) — P(ALERT) — "immediate danger" DangerHead: 256 → 1 (sigmoid) — P(DANGER) — "any non-SILENT response needed" Decision logic: P(ALERT) > τ_a → ALERT P(DANGER) > τ_d → OBSERVE else → SILENT """ def __init__(self, hidden_dim, dropout=0.2): super().__init__() self.hidden_dim = hidden_dim self.action_embedding = nn.Embedding(3, 16) input_dim = hidden_dim + 2 + 16 # belief + tta_mean + tta_var + action_emb self.shared = nn.Sequential( nn.Linear(input_dim, 512), nn.GELU(), nn.Dropout(dropout), nn.Linear(512, 256), nn.GELU(), nn.Dropout(dropout), ) # Independent binary outputs (logit space — apply sigmoid externally) self.alert_head = nn.Linear(256, 1) self.danger_head = nn.Linear(256, 1) # Balanced init nn.init.zeros_(self.alert_head.weight) nn.init.zeros_(self.alert_head.bias) nn.init.zeros_(self.danger_head.weight) nn.init.zeros_(self.danger_head.bias) def forward(self, hidden_state, tta_mean, tta_var, prev_action): """ Returns: alert_logit: [B] — raw logit for ALERT danger_logit: [B] — raw logit for DANGER (OBSERVE+ALERT vs SILENT) """ action_emb = self.action_embedding(prev_action) features = torch.cat([ hidden_state, tta_mean.unsqueeze(-1), tta_var.unsqueeze(-1), action_emb, ], dim=-1) h = self.shared(features) alert_logit = self.alert_head(h).squeeze(-1) # [B] danger_logit = self.danger_head(h).squeeze(-1) # [B] return alert_logit, danger_logit def predict(self, alert_logit, danger_logit, tau_alert=0.5, tau_danger=0.5): """ Hierarchical decision with configurable thresholds. Returns: preds: [B] long — 0=SILENT, 1=OBSERVE, 2=ALERT p_alert: [B] float — sigmoid probability of ALERT p_danger: [B] float — sigmoid probability of DANGER """ p_alert = torch.sigmoid(alert_logit) p_danger = torch.sigmoid(danger_logit) B = p_alert.shape[0] preds = torch.zeros(B, dtype=torch.long, device=p_alert.device) preds[p_danger > tau_danger] = 1 # OBSERVE preds[p_alert > tau_alert] = 2 # ALERT overrides OBSERVE return preds, p_alert, p_danger class TrajectoryAwarePolicyHead(nn.Module): """ Trajectory-Aware Policy Head — explicit per-timestep danger estimation with trajectory shape features for robust false alarm suppression. Key insight (Pjetri et al., ECCV-W 2024 extension): True collisions have monotonically increasing danger trajectories; false alarms / near-misses have NON-monotonic danger (rise then fall). OBSERVE acts as a sequential hypothesis test / confirmation buffer. Asymmetric monotonic constraint: enforce d(t)↑ only for ALERT; allow non-monotonic trajectories for OBSERVE. Architecture: Step 1: Per-timestep danger estimation belief[t] → proj(256) ⊕ tta_mean[t] ⊕ tta_var[t] → MLP(258→128→1) → σ → d[t] Step 2: Trajectory feature extraction (all differentiable) d_last, d_mean, d_max, d_gradient, d_acceleration, d_volatility, d_rise_ratio Step 3 (optional): GRU residual path for implicit temporal patterns Step 4: Classification [7 traj features ⊕ tta_last ⊕ tta_var_last (⊕ GRU_hidden)] → MLP → 3-class logits """ def __init__(self, hidden_dim=2048, gru_hidden=256, n_actions=3, dropout=0.2, use_gru=True): super().__init__() self.hidden_dim = hidden_dim self.use_gru = use_gru self.n_actions = n_actions self.gru_hidden = gru_hidden # Step 1: per-timestep danger estimator self.belief_proj = nn.Linear(hidden_dim, 256) self.danger_estimator = nn.Sequential( nn.Linear(258, 128), # 256 proj + 2 (tta_mean, tta_var) nn.GELU(), nn.Dropout(dropout), nn.Linear(128, 1), ) # init danger output near 0 (sigmoid(0)=0.5) → slight negative bias nn.init.zeros_(self.danger_estimator[-1].weight) nn.init.constant_(self.danger_estimator[-1].bias, -0.5) # Step 3 (optional): GRU residual if use_gru: self.gru = nn.GRU(258, gru_hidden, num_layers=1, batch_first=True, dropout=0) # Step 4: classifier # 7 trajectory features + 2 (tta_last, tta_var_last) clf_input_dim = 7 + 2 if use_gru: clf_input_dim += gru_hidden self.classifier = nn.Sequential( nn.Linear(clf_input_dim, 128), nn.GELU(), nn.Dropout(dropout), nn.Linear(128, 64), nn.GELU(), nn.Dropout(dropout), nn.Linear(64, n_actions), ) def forward(self, belief_seq, tta_mean_seq, tta_var_seq): """ Args: belief_seq: [B, T, hidden_dim] tta_mean_seq: [B, T] tta_var_seq: [B, T] Returns: logits: [B, n_actions] danger_t: [B, T] — per-timestep danger scores (for auxiliary loss) """ B, T, _ = belief_seq.shape # Step 1: per-timestep danger proj = self.belief_proj(belief_seq) # [B, T, 256] tta_feat = torch.stack([tta_mean_seq, tta_var_seq], dim=-1) # [B, T, 2] x = torch.cat([proj, tta_feat], dim=-1) # [B, T, 258] danger_t = torch.sigmoid( self.danger_estimator(x).squeeze(-1) # [B, T] ) # Step 2: trajectory features (all differentiable) d_last = danger_t[:, -1] # [B] d_mean = danger_t.mean(dim=1) # [B] d_max = danger_t.max(dim=1).values # [B] delta_d = danger_t[:, 1:] - danger_t[:, :-1] # [B, T-1] d_gradient = delta_d.mean(dim=1) # [B] d_rise_ratio = (delta_d > 0).float().mean(dim=1) # [B] if T > 2: d_volatility = delta_d.std(dim=1) # [B] delta2 = delta_d[:, 1:] - delta_d[:, :-1] # [B, T-2] d_acceleration = delta2.mean(dim=1) # [B] else: d_volatility = torch.zeros(B, device=belief_seq.device) d_acceleration = torch.zeros(B, device=belief_seq.device) traj_features = torch.stack([ d_last, d_mean, d_max, d_gradient, d_acceleration, d_volatility, d_rise_ratio, ], dim=-1) # [B, 7] # TTA context from last timestep tta_last = tta_mean_seq[:, -1].unsqueeze(-1) # [B, 1] tta_var_last = tta_var_seq[:, -1].unsqueeze(-1) # [B, 1] clf_input = torch.cat([traj_features, tta_last, tta_var_last], dim=-1) # [B, 9] # Step 3 (optional): GRU residual if self.use_gru: _, h_n = self.gru(x) # [1, B, gru_hidden] clf_input = torch.cat([clf_input, h_n.squeeze(0)], dim=-1) # Step 4: classification logits = self.classifier(clf_input) # [B, n_actions] return logits, danger_t class TrajectoryAwarePOMDPHead(nn.Module): """Action-conditioned POMDP variant of TrajectoryAwarePolicyHead. Per-timestep belief update with explicit POMDP-style state transitions: h_t = GRU([belief_t ⊕ act_emb(prev_action_t) ⊕ tta_emb(tta_t)], h_{t-1}) Outputs at each timestep: - logits_t [3] per-step 3-class state (SILENT/OBSERVE/ALERT) - danger_t per-step P(danger), kept for v7 monotonic-aux loss - tta_pred_t per-step log-TTA reconstruction (auxiliary regularizer) Designed to be trained with teacher-forcing (`prev_action_t` = `action_label_seq[t-1]`); at inference time, can run autoregressively (use prev step's argmax as next prev_action) or with prev_action=SILENT init. """ def __init__(self, hidden_dim=2560, gru_hidden=256, n_actions=3, dropout=0.2, action_emb_dim=32, tta_emb_dim=32): super().__init__() self.hidden_dim = hidden_dim self.gru_hidden = gru_hidden self.n_actions = n_actions # Action embedding (SILENT=0 / OBSERVE=1 / ALERT=2 + START=3 sentinel) self.action_emb = nn.Embedding(n_actions + 1, action_emb_dim) self.START_TOKEN = n_actions # 3 = teacher-forcing start sentinel # TTA encoder (mean + var → embedding) — match shape of action_emb self.tta_encoder = nn.Sequential( nn.Linear(2, tta_emb_dim), nn.GELU(), nn.Linear(tta_emb_dim, tta_emb_dim), ) # Belief projection to bring 2560 → 256 self.belief_proj = nn.Linear(hidden_dim, 256) gru_input_dim = 256 + action_emb_dim + tta_emb_dim self.gru = nn.GRU(gru_input_dim, gru_hidden, num_layers=1, batch_first=True, dropout=0) # Per-step state head (3-class) self.state_head = nn.Sequential( nn.Linear(gru_hidden, 128), nn.GELU(), nn.Dropout(dropout), nn.Linear(128, n_actions), ) # Per-step danger head (binary, for v7-style aux loss) self.danger_head = nn.Sequential( nn.Linear(gru_hidden, 64), nn.GELU(), nn.Linear(64, 1), ) nn.init.zeros_(self.danger_head[-1].weight) nn.init.constant_(self.danger_head[-1].bias, -0.5) # Per-step TTA prediction head (log-TTA regression, for aux loss) self.tta_pred_head = nn.Sequential( nn.Linear(gru_hidden, 64), nn.GELU(), nn.Linear(64, 1), ) def forward(self, belief_seq, tta_mean_seq, tta_var_seq, prev_action_seq=None): """ Args: belief_seq: [B, T, hidden_dim] tta_mean_seq: [B, T] tta_var_seq: [B, T] prev_action_seq: [B, T] long, prev_action_seq[t] = action at t-1 (teacher-forcing). If None, use START token. Returns: logits_seq: [B, T, n_actions] per-step state danger_seq: [B, T] per-step P(danger) tta_pred_seq: [B, T] per-step log-TTA prediction """ B, T, _ = belief_seq.shape if prev_action_seq is None: prev_action_seq = torch.full( (B, T), self.START_TOKEN, dtype=torch.long, device=belief_seq.device, ) proj = self.belief_proj(belief_seq) # [B, T, 256] a_emb = self.action_emb(prev_action_seq) # [B, T, ae_dim] tta_feat = torch.stack( [tta_mean_seq, tta_var_seq], dim=-1) # [B, T, 2] tta_emb = self.tta_encoder(tta_feat) # [B, T, te_dim] x = torch.cat([proj, a_emb, tta_emb], dim=-1) # [B, T, in] h_seq, _ = self.gru(x) # [B, T, H] logits_seq = self.state_head(h_seq) # [B, T, 3] danger_seq = torch.sigmoid( self.danger_head(h_seq).squeeze(-1)) # [B, T] tta_pred_seq = self.tta_pred_head(h_seq).squeeze(-1) # [B, T] log-TTA return logits_seq, danger_seq, tta_pred_seq class TemporalPolicyHead(nn.Module): """ Temporal Belief Aggregation — GRU over K consecutive observation windows to capture danger escalation dynamics that single-frame beliefs miss. Motivation: - Single-frame AP locked at 0.24: beliefs separate dangerous/safe (AP=0.89) but cannot distinguish OBSERVE from ALERT. - Temporal gradient (danger increasing → ALERT vs stable → OBSERVE) requires multi-window context. Architecture: belief_seq [B, T, H] → Linear(H, 256) → concat(tta_mean, tta_var) → GRU(258, 256) → last hidden → MLP(256→128→3) → logits [B, 3] """ def __init__(self, hidden_dim=2048, gru_hidden=256, n_actions=3, dropout=0.2): super().__init__() self.hidden_dim = hidden_dim self.gru_hidden = gru_hidden self.belief_proj = nn.Linear(hidden_dim, 256) gru_input_dim = 256 + 2 # projected belief + tta_mean + tta_var self.gru = nn.GRU(gru_input_dim, gru_hidden, num_layers=1, batch_first=True, dropout=0) self.head = nn.Sequential( nn.Linear(gru_hidden, 256), nn.GELU(), nn.Dropout(dropout), nn.Linear(256, 128), nn.GELU(), nn.Dropout(dropout), nn.Linear(128, n_actions), ) def forward(self, belief_seq, tta_mean_seq, tta_var_seq): """ Args: belief_seq: [B, T, hidden_dim] tta_mean_seq: [B, T] tta_var_seq: [B, T] Returns: logits: [B, n_actions] """ proj = self.belief_proj(belief_seq) # [B, T, 256] tta = torch.stack([tta_mean_seq, tta_var_seq], dim=-1) # [B, T, 2] x = torch.cat([proj, tta], dim=-1) # [B, T, 258] _, h_n = self.gru(x) # [1, B, gru_hidden] return self.head(h_n.squeeze(0)) # [B, 3] # ═══════════════════════════════════════════════════════════════════════════════ # M10: Multi-Query PMA Aggregator (Pooling by Multi-head Attention) # Lee et al., "Set Transformer", ICML 2019 — universal set function approximator # ═══════════════════════════════════════════════════════════════════════════════ class MultiQueryPMAAggregator(nn.Module): """ K learnable query tokens cross-attend to per-frame belief tokens → K aggregated belief vectors that can specialise on orthogonal semantic axes (entity / motion / temporal / risk). Replaces mean_pool which collapses all frames to 1 vector. Input: beliefs_frame: [B, F, D] per-frame beliefs (from per_frame cache) valid_mask: [B, F] bool True = valid frame, False = padded/missing Output: queries: [B, K, d_out] K aggregated vectors attn: [B, K, F] attention weights (for interpretability/aux) """ def __init__( self, d_in: int = 2048, d_out: int = 512, K: int = 4, n_heads: int = 4, dropout: float = 0.1, ): super().__init__() self.K = K self.d_out = d_out # Learnable queries — one per semantic axis self.queries = nn.Parameter(torch.randn(1, K, d_out) * 0.02) self.in_proj = nn.Linear(d_in, d_out) self.mha = nn.MultiheadAttention( d_out, n_heads, dropout=dropout, batch_first=True, ) self.ln1 = nn.LayerNorm(d_out) self.ffn = nn.Sequential( nn.Linear(d_out, d_out * 2), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_out * 2, d_out), ) self.ln2 = nn.LayerNorm(d_out) def forward(self, beliefs_frame: torch.Tensor, valid_mask: torch.Tensor = None): B = beliefs_frame.shape[0] kv = self.in_proj(beliefs_frame.float()) # [B, F, d_out] q = self.queries.expand(B, -1, -1).contiguous() # [B, K, d_out] # key_padding_mask: True means *mask out* (invalid) kpm = None if valid_mask is not None: m = valid_mask.to(kv.device).bool() kpm = ~m # Guard against all-invalid rows (would give NaN in attention) all_invalid = kpm.all(dim=-1) if all_invalid.any(): kpm = kpm.clone() kpm[all_invalid, 0] = False # allow at least one slot attn_out, attn_w = self.mha( q, kv, kv, key_padding_mask=kpm, need_weights=True, average_attn_weights=True, ) h = self.ln1(q + attn_out) h = self.ln2(h + self.ffn(h)) return h, attn_w def orthogonality_loss(self) -> torch.Tensor: """L_ortho = ||Q Q^T - I||_F^2 / K^2 — prevents query collapse.""" q = self.queries.squeeze(0) # [K, d_out] q = F.normalize(q, dim=-1) gram = q @ q.t() # [K, K] eye = torch.eye(self.K, device=q.device, dtype=q.dtype) return ((gram - eye) ** 2).mean() class MultiQueryPolicyHead(nn.Module): """ Full M10 PolicyHead: aggregator + classifier. Pipeline: [B, F, D] per_frame beliefs → MultiQueryPMAAggregator → [B, K, d_out] → flatten [B, K*d_out] → concat (tta_mean, tta_var, prev_action embedding) → MLP → [B, 3] """ def __init__( self, hidden_dim: int = 2048, d_out: int = 512, K: int = 4, n_heads: int = 4, n_actions: int = 3, dropout: float = 0.2, ): super().__init__() self.K = K self.d_out = d_out self.n_actions = n_actions self.aggregator = MultiQueryPMAAggregator( d_in=hidden_dim, d_out=d_out, K=K, n_heads=n_heads, dropout=0.1, ) self.action_embedding = nn.Embedding(n_actions, 16) clf_input = K * d_out + 2 + 16 self.classifier = nn.Sequential( nn.Linear(clf_input, 512), nn.GELU(), nn.Dropout(dropout), nn.Linear(512, 256), nn.GELU(), nn.Dropout(dropout), nn.Linear(256, n_actions), ) def forward( self, beliefs_frame: torch.Tensor, # [B, F, D] valid_mask: torch.Tensor, # [B, F] bool tta_mean: torch.Tensor, # [B] tta_var: torch.Tensor, # [B] prev_action: torch.Tensor, # [B] long ): agg, attn_w = self.aggregator(beliefs_frame, valid_mask) # [B, K, d_out] flat = agg.reshape(agg.shape[0], -1) # [B, K*d_out] act_emb = self.action_embedding(prev_action) # [B, 16] x = torch.cat([ flat, tta_mean.unsqueeze(-1), tta_var.unsqueeze(-1), act_emb, ], dim=-1) logits = self.classifier(x) return logits, attn_w class TransformerTemporalHead(nn.Module): """Transformer-based binary collision scorer over per-frame beliefs. Self-attention lets every frame pair interact directly, capturing patterns like "frame 7 looks dangerous vs frame 3 was safe" that sequential models (GRU) struggle with due to recency bias. Input: beliefs_frame [B, T, 2048], tta_mean [B], tta_var [B] Output: binary logit [B] """ def __init__(self, hidden_dim=2048, d_model=256, nhead=8, n_layers=2, dropout=0.1): super().__init__() self.d_model = d_model self.frame_proj = nn.Sequential( nn.Linear(hidden_dim + 2, d_model), nn.LayerNorm(d_model), ) self.cls_token = nn.Parameter(torch.randn(1, 1, d_model) * 0.02) self.register_buffer('pe', self._sinusoidal_pe(65, d_model)) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=dropout, batch_first=True, activation='gelu', ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=n_layers) self.head = nn.Sequential( nn.Linear(d_model, 128), nn.GELU(), nn.Dropout(dropout), nn.Linear(128, 1), ) @staticmethod def _sinusoidal_pe(max_len, d_model): pe = torch.zeros(max_len, d_model) pos = torch.arange(max_len).unsqueeze(1).float() div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(pos * div) pe[:, 1::2] = torch.cos(pos * div) return pe.unsqueeze(0) def forward(self, beliefs_frame, tta_mean, tta_var, valid_mask=None): B, T, _ = beliefs_frame.shape tm = tta_mean.unsqueeze(1).unsqueeze(2).expand(B, T, 1) tv = tta_var.unsqueeze(1).unsqueeze(2).expand(B, T, 1) h = self.frame_proj(torch.cat([beliefs_frame, tm, tv], dim=-1)) cls = self.cls_token.expand(B, -1, -1) h = torch.cat([cls, h], dim=1) + self.pe[:, :T + 1, :].to(h.device) pad_mask = None if valid_mask is not None: cls_valid = torch.ones(B, 1, dtype=torch.bool, device=h.device) pad_mask = ~torch.cat([cls_valid, valid_mask], dim=1) h = self.encoder(h, src_key_padding_mask=pad_mask) return self.head(h[:, 0, :]).squeeze(-1) # ═══════════════════════════════════════════════════════════════════════════════ # M9: Spatial Attention Aggregator (for spatial4x4 cache) # Learnable query over 16 spatial cells per frame → per-frame belief; # then mean-over-F (or stack for downstream temporal model). # ═══════════════════════════════════════════════════════════════════════════════ class SpatialAttentionAggregator(nn.Module): """ Input: beliefs_grid: [B, F, 16, D] spatial4x4 cache valid_frames: [B, F] bool Output: per_frame: [B, F, d_out] spatially attended per-frame belief frame_mean: [B, d_out] valid-frame mean of per_frame spatial_attn: [B, F, 16] spatial attention weights """ def __init__( self, d_in: int = 2048, d_out: int = 512, n_heads: int = 4, dropout: float = 0.1, ): super().__init__() self.d_out = d_out self.in_proj = nn.Linear(d_in, d_out) self.spatial_query = nn.Parameter(torch.randn(1, 1, d_out) * 0.02) self.mha = nn.MultiheadAttention( d_out, n_heads, dropout=dropout, batch_first=True, ) self.ln = nn.LayerNorm(d_out) def forward(self, beliefs_grid: torch.Tensor, valid_frames: torch.Tensor): B, F_, S, D = beliefs_grid.shape x = self.in_proj(beliefs_grid.float()) # [B, F, 16, d_out] # Flatten batch and frame for per-frame spatial attention x_flat = x.reshape(B * F_, S, self.d_out) # [B*F, 16, d_out] q = self.spatial_query.expand(B * F_, -1, -1).contiguous() attn_out, attn_w = self.mha( q, x_flat, x_flat, need_weights=True, average_attn_weights=True, ) per_frame = self.ln(attn_out).squeeze(1) # [B*F, d_out] per_frame = per_frame.reshape(B, F_, self.d_out) # [B, F, d_out] spatial_attn = attn_w.reshape(B, F_, S) # [B, F, 16] # Valid-frame mean pool (M9 single-belief output) valid = valid_frames.to(per_frame.device).float().unsqueeze(-1) # [B, F, 1] denom = valid.sum(dim=1).clamp(min=1e-6) frame_mean = (per_frame * valid).sum(dim=1) / denom # [B, d_out] return per_frame, frame_mean, spatial_attn class SpatialPolicyHead(nn.Module): """ Full M9 PolicyHead: spatial attention + classifier (single-belief output). Uses spatial4x4 cache. For a temporal variant, feed per_frame into GRU/PMA. """ def __init__( self, hidden_dim: int = 2048, d_out: int = 512, n_heads: int = 4, n_actions: int = 3, dropout: float = 0.2, ): super().__init__() self.n_actions = n_actions self.aggregator = SpatialAttentionAggregator( d_in=hidden_dim, d_out=d_out, n_heads=n_heads, dropout=0.1, ) self.action_embedding = nn.Embedding(n_actions, 16) clf_input = d_out + 2 + 16 self.classifier = nn.Sequential( nn.Linear(clf_input, 512), nn.GELU(), nn.Dropout(dropout), nn.Linear(512, 256), nn.GELU(), nn.Dropout(dropout), nn.Linear(256, n_actions), ) def forward( self, beliefs_grid: torch.Tensor, # [B, F, 16, D] valid_frames: torch.Tensor, # [B, F] tta_mean: torch.Tensor, tta_var: torch.Tensor, prev_action: torch.Tensor, ): _, frame_mean, spatial_attn = self.aggregator(beliefs_grid, valid_frames) act_emb = self.action_embedding(prev_action) x = torch.cat([ frame_mean, tta_mean.unsqueeze(-1), tta_var.unsqueeze(-1), act_emb, ], dim=-1) logits = self.classifier(x) return logits, spatial_attn class PatchTemporalHead(nn.Module): """Binary collision head over V-JEPA2 patch features. Input: patches [B, T, P, D] (T=16 frames, P=256 patches, D=1024) 1. Linear(D, hidden) projection per patch 2. Spatial self-attention within each frame (1 layer, pooled via learnable CLS) 3. Temporal self-attention across frame-level CLS summaries (2 layers) 4. Temporal CLS → MLP → binary logit """ def __init__( self, in_dim: int = 1024, hidden_dim: int = 256, n_spatial_layers: int = 1, n_temporal_layers: int = 2, n_heads: int = 4, dropout: float = 0.1, max_frames: int = 32, ): super().__init__() self.hidden_dim = hidden_dim self.proj = nn.Linear(in_dim, hidden_dim) self.spatial_cls = nn.Parameter(torch.zeros(1, 1, hidden_dim)) nn.init.trunc_normal_(self.spatial_cls, std=0.02) spatial_layer = nn.TransformerEncoderLayer( d_model=hidden_dim, nhead=n_heads, dim_feedforward=hidden_dim * 4, dropout=dropout, batch_first=True, activation="gelu", norm_first=True, ) self.spatial_encoder = nn.TransformerEncoder(spatial_layer, num_layers=n_spatial_layers) self.temporal_cls = nn.Parameter(torch.zeros(1, 1, hidden_dim)) nn.init.trunc_normal_(self.temporal_cls, std=0.02) self.temporal_pos = nn.Parameter(torch.zeros(1, max_frames + 1, hidden_dim)) nn.init.trunc_normal_(self.temporal_pos, std=0.02) temporal_layer = nn.TransformerEncoderLayer( d_model=hidden_dim, nhead=n_heads, dim_feedforward=hidden_dim * 4, dropout=dropout, batch_first=True, activation="gelu", norm_first=True, ) self.temporal_encoder = nn.TransformerEncoder(temporal_layer, num_layers=n_temporal_layers) self.norm = nn.LayerNorm(hidden_dim) self.classifier = nn.Sequential( nn.Linear(hidden_dim, 128), nn.GELU(), nn.Dropout(dropout), nn.Linear(128, 1), ) def forward(self, patches: torch.Tensor) -> torch.Tensor: """patches: [B, T, P, D] → logits: [B]""" B, T, P, D = patches.shape assert T + 1 <= self.temporal_pos.shape[1], ( f"T={T} exceeds max_frames; increase max_frames in PatchTemporalHead" ) x = self.proj(patches) # [B, T, P, H] x = x.view(B * T, P, self.hidden_dim) cls = self.spatial_cls.expand(B * T, -1, -1) # [B*T, 1, H] x = torch.cat([cls, x], dim=1) # [B*T, 1+P, H] x = self.spatial_encoder(x) frame_tokens = x[:, 0] # [B*T, H] frame_tokens = frame_tokens.view(B, T, self.hidden_dim) tcls = self.temporal_cls.expand(B, -1, -1) # [B, 1, H] seq = torch.cat([tcls, frame_tokens], dim=1) # [B, 1+T, H] seq = seq + self.temporal_pos[:, : 1 + T] seq = self.temporal_encoder(seq) clip = self.norm(seq[:, 0]) # [B, H] return self.classifier(clip).squeeze(-1) # [B]