File size: 8,806 Bytes
1e05592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""AdaptiveWindowModule β€” VLAlert-X core architectural innovation.

Maps (current policy distribution + hazard logits + belief summary) to a
window choice for the *next* tick:

    w_{t+1} = AdaptiveWindow(pi_t, hazard_logits_t, belief_summary_t)

The next tick's belief vector is then extracted from frames sampled
according to w_{t+1} ∈ {narrow, mid, wide}. This closes the
"OBSERVE-as-action" loop: when the policy commits to OBSERVE, the
window narrows on the *next* tick, providing tighter temporal evidence
for the subsequent action decision.

Window index convention (matches build_adaptive_trajectories.py):
    0 = narrow (1 s span, 8 frames at ~0.125 s stride)
    1 = mid    (2 s span, 8 frames at ~0.25 s stride)   -- legacy default
    2 = wide   (4 s span, 8 frames at ~0.5 s stride)

Training protocol β€” 3-stage curriculum (see plan Β§3.2 of vlalert-x-upgrade.md):
    Stage 1 (epoch 1-2): 100 % oracle window (deterministic from action)
    Stage 2 (epoch 3-4): 50/50 oracle / student-predicted window
    Stage 3 (epoch 5-6): 100 % student-predicted window (with
                         straight-through gradient on the discrete choice)

Hazard-conditional bias: at inference, the window logits are biased by
a learned per-hazard correction. The bias maps each of the 8 hazard
categories to a 3-D tilt over windows. Defaults (initialised from
empirical priors):
    pedestrian / vrurider     -> +1.0 bias on dim 0 (narrow)
    vehicle_cross / oncoming  -> +0.5 bias on dim 0 (narrow)
    vehicle_lead              -> +0.3 bias on dim 1 (mid)
    weather / infrastructure  -> +0.5 bias on dim 1 (mid)
    none                      -> +1.0 bias on dim 2 (wide)
"""
from __future__ import annotations

from typing import Optional, Tuple

import torch
import torch.nn as nn
import torch.nn.functional as F


# Window-index convention
WINDOW_NARROW = 0
WINDOW_MID    = 1
WINDOW_WIDE   = 2

# Hazard categories (matches Phase 1.1 GPT-5 schema)
HAZARD_PEDESTRIAN     = 0
HAZARD_VRURIDER       = 1
HAZARD_VEHICLE_CROSS  = 2
HAZARD_VEHICLE_ONCOMING = 3
HAZARD_VEHICLE_LEAD   = 4
HAZARD_WEATHER        = 5
HAZARD_INFRASTRUCTURE = 6
HAZARD_NONE           = 7
N_HAZARDS = 8


# Empirical hazard→window prior (used to initialise hazard_bias)
HAZARD_BIAS_INIT = torch.tensor([
    # narrow, mid,  wide
    [ 1.0,    0.0,  0.0],   # pedestrian
    [ 1.0,    0.0,  0.0],   # vrurider
    [ 0.5,    0.5,  0.0],   # vehicle_cross
    [ 0.5,    0.5,  0.0],   # vehicle_oncoming
    [ 0.0,    0.5,  0.0],   # vehicle_lead
    [ 0.0,    0.5,  0.0],   # weather
    [ 0.0,    0.5,  0.0],   # infrastructure
    [ 0.0,    0.0,  1.0],   # none
], dtype=torch.float32)


class AdaptiveWindowModule(nn.Module):
    """Lightweight MLP head that emits a 3-window choice.

    Inputs:
      pi_t         : [B, 3]      current-tick policy distribution (softmax)
      hazard_logits: [B, 8]      hazard-category logits from the SFT'd VLM
      belief_summary: [B, D]     mean-pooled belief at current tick (D=2560 for Qwen3-VL-4B)

    Output:
      window_logits: [B, 3]      logits over {narrow, mid, wide}
    """

    def __init__(self,
                 belief_dim: int = 2560,
                 hidden: int = 128,
                 dropout: float = 0.1,
                 use_hazard_bias: bool = True,
                 hazard_bias_lr_mult: float = 0.5):
        super().__init__()
        # Belief summariser (compresses 2560-D belief to 256-D)
        self.belief_proj = nn.Sequential(
            nn.Linear(belief_dim, 256),
            nn.GELU(),
            nn.LayerNorm(256),
        )

        # Main classifier: pi_t (3) + hazard_logits (8) + belief_proj (256) -> 3 windows
        in_dim = 3 + N_HAZARDS + 256
        self.mlp = nn.Sequential(
            nn.Linear(in_dim, hidden),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(hidden, 3),
        )

        # Hazard-conditional bias on window logits, initialised from empirical prior.
        # Uses a smaller LR multiplier so the prior survives early epochs.
        self.use_hazard_bias = use_hazard_bias
        if use_hazard_bias:
            self.hazard_bias = nn.Parameter(HAZARD_BIAS_INIT.clone())
            self.hazard_bias_lr_mult = hazard_bias_lr_mult

    def forward(self,
                pi_t: torch.Tensor,
                hazard_logits: torch.Tensor,
                belief_summary: torch.Tensor) -> torch.Tensor:
        """Returns raw window logits [B, 3]."""
        b_proj = self.belief_proj(belief_summary)
        z = torch.cat([pi_t, hazard_logits, b_proj], dim=-1)
        logits = self.mlp(z)

        if self.use_hazard_bias:
            # Soft hazard mixture: bias = hazard_softmax Β· HAZARD_BIAS_INIT [B, 3]
            hazard_probs = F.softmax(hazard_logits, dim=-1)        # [B, 8]
            bias = hazard_probs @ self.hazard_bias                  # [B, 3]
            logits = logits + bias

        return logits

    @torch.no_grad()
    def predict_window(self,
                        pi_t: torch.Tensor,
                        hazard_logits: torch.Tensor,
                        belief_summary: torch.Tensor,
                        temperature: float = 1.0,
                        sample: bool = False) -> torch.Tensor:
        """Inference-time window choice as integer in {0,1,2}.

        Args:
            sample: if True, sample from softmax (Stage 2/3 of training-loop
                with stochastic sampling); if False, take argmax (deployment).
        """
        logits = self.forward(pi_t, hazard_logits, belief_summary) / max(temperature, 1e-3)
        if sample:
            probs = F.softmax(logits, dim=-1)
            choice = torch.multinomial(probs, num_samples=1).squeeze(-1)
        else:
            choice = logits.argmax(dim=-1)
        return choice

    def param_groups(self, base_lr: float):
        """Yield optimiser param groups, applying lr-mult to hazard_bias."""
        bias_params, other_params = [], []
        for n, p in self.named_parameters():
            if n.endswith("hazard_bias"):
                bias_params.append(p)
            else:
                other_params.append(p)
        groups = [{"params": other_params, "lr": base_lr}]
        if bias_params:
            groups.append({"params": bias_params,
                           "lr": base_lr * self.hazard_bias_lr_mult})
        return groups


# ───────────────────────────── helpers ──────────────────────────────────

def oracle_window_from_action(action: torch.Tensor) -> torch.Tensor:
    """Map per-tick action label {0=SILENT, 1=OBSERVE, 2=ALERT} to window.

    SILENT  β†’ wide   (window_idx 2)
    OBSERVE β†’ mid    (window_idx 1)
    ALERT   β†’ narrow (window_idx 0)
    """
    table = torch.tensor([WINDOW_WIDE, WINDOW_MID, WINDOW_NARROW],
                         dtype=torch.long, device=action.device)
    return table[action.clamp(min=0, max=2)]


def scheduled_sampling_window(stage: int,
                                oracle_window: torch.Tensor,
                                student_window: torch.Tensor,
                                rng: Optional[torch.Generator] = None,
                                p_oracle_stage2: float = 0.5
                                ) -> torch.Tensor:
    """Pick window per-tick according to curriculum stage.

    Stage 1: 100 % oracle.
    Stage 2: per-tick coin flip (p_oracle_stage2) between oracle / student.
    Stage 3: 100 % student.
    """
    if stage == 1:
        return oracle_window
    if stage == 3:
        return student_window
    # Stage 2: mixed
    p = torch.rand(oracle_window.shape, generator=rng,
                   device=oracle_window.device)
    return torch.where(p < p_oracle_stage2, oracle_window, student_window)


def straight_through_window_select(window_logits: torch.Tensor,
                                    belief_per_window: torch.Tensor) -> torch.Tensor:
    """Differentiable window-conditioned belief lookup with straight-through.

    Args:
        window_logits     : [B, 3]
        belief_per_window : [B, 3, F, D]  pre-computed beliefs for all 3 windows

    Returns:
        belief : [B, F, D]  the chosen window's belief, with straight-through
                 gradient flowing back into window_logits.
    """
    probs = F.softmax(window_logits, dim=-1)               # [B, 3]
    onehot = F.one_hot(window_logits.argmax(dim=-1), 3).float()  # [B, 3]
    # straight-through: forward = onehot, backward = softmax probs
    soft = onehot + (probs - probs.detach())
    soft = soft.unsqueeze(-1).unsqueeze(-1)                # [B, 3, 1, 1]
    belief = (belief_per_window * soft).sum(dim=1)         # [B, F, D]
    return belief