File size: 14,009 Bytes
d4cbafd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37c61d4
d4cbafd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37c61d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4cbafd
 
 
 
 
 
37c61d4
 
 
 
d4cbafd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
"""
Future Euclidean Interaction Graph Module for MoFlow-NBA.

Core idea (ported from srtp/models/denoiser_graph_v4.py::LocalEncoder_v4_3
and srtp/models/denoiser.py::graphDenoiser_uncertainty_v4_4_nba):

  During flow matching denoising, the current noisy trajectory y_t carries
  approximate future agent positions.  For each agent pair at each future
  timestep, the Euclidean relative position is computed from y_t, encoded
  as an edge feature, and propagated through graph attention β€” letting each
  agent's embedding be informed by where other agents are predicted to be.

Memory-efficient design for MoFlow's [B=250, K=20, A=11] NBA setting:
  The naive approach (BΓ—K = 5000 scenes Γ— 110 edges = 550 K edges) OOMs on
  the transformer FFN in the original EdgeTemporalEncoderCausal.
  Solution: compute edge features once from K-mode-averaged future positions
  (β†’ B = 250 scenes, 27.5 K edges), then expand to BΓ—K by replicating the
  per-scene edge attributes before the GNN pass (55 K nodes, 550 K edges in
  one vectorised forward β€” each set of 128-D tensors is ~280 MB, well within
  the A6000's 48 GB).
"""

import os
import torch
import torch.nn as nn
from torch_geometric.nn.conv import MessagePassing
from torch_geometric.typing import OptTensor
from torch_geometric.utils import softmax


# ---------------------------------------------------------------------------
# Graph attention layer (adapted from GlobalInteractorLayer_v2 in
# srtp/models/denoiser_graph_v4.py)
# ---------------------------------------------------------------------------

class FutureGeomAttnLayer(MessagePassing):
    """Time-conditioned graph attention with future-geometry edge features.

    Convention (PyG source_to_target, matching srtp's variable naming):
      edge_index[0] = source = neighbor j  (sends message)
      edge_index[1] = target = center  i   (receives message)
      x_i = center feature, x_j = neighbor feature
    """

    def __init__(self, embed_dim: int, num_heads: int = 4,
                 dropout: float = 0.1, time_dim: int = 128,
                 attn_gamma: float = 4.0, **kwargs):
        super().__init__(aggr='add', node_dim=0, **kwargs)
        self.embed_dim = embed_dim
        self.num_heads = num_heads
        self.head_dim  = embed_dim // num_heads
        self.scale     = self.head_dim ** 0.5
        self.attn_gamma = attn_gamma

        # node-feature projections
        self.lin_q = nn.Linear(embed_dim, embed_dim)
        self.lin_k = nn.Linear(embed_dim, embed_dim)
        self.lin_v = nn.Linear(embed_dim, embed_dim)

        # time-conditioned bias into q / k / v
        self.t2qkv = nn.Linear(time_dim, 3 * embed_dim)

        # edge projections (carry the future geometry)
        self.lin_k_edge = nn.Linear(embed_dim, embed_dim)
        self.lin_v_edge = nn.Linear(embed_dim, embed_dim)

        self.out_proj  = nn.Linear(embed_dim, embed_dim)
        self.proj_drop = nn.Dropout(dropout)
        self.attn_drop = nn.Dropout(dropout)
        self.norm1 = nn.LayerNorm(embed_dim)
        self.norm2 = nn.LayerNorm(embed_dim)
        self.mlp = nn.Sequential(
            nn.Linear(embed_dim, embed_dim * 4),
            nn.ReLU(inplace=True),
            nn.Dropout(dropout),
            nn.Linear(embed_dim * 4, embed_dim),
            nn.Dropout(dropout),
        )

    def forward(self, x, edge_index, edge_attr, temb_agent, tau, size=None):
        x = x + self._mha_block(self.norm1(x), edge_index, edge_attr,
                                 temb_agent, tau, size)
        x = x + self._ff_block(self.norm2(x))
        return x

    def message(self, x_i, x_j, edge_attr,
                temb_agent_i, temb_agent_j, tau_i, tau_j,
                index, ptr, size_i):
        bias_i = self.t2qkv(temb_agent_i)
        bias_j = self.t2qkv(temb_agent_j)
        tq_i = bias_i.chunk(3, dim=-1)[0]
        tk_j = bias_j.chunk(3, dim=-1)[1]
        tv_j = bias_j.chunk(3, dim=-1)[2]

        H, Dh = self.num_heads, self.head_dim
        q = (self.lin_q(x_i) + tq_i).view(-1, H, Dh)
        k = (self.lin_k(x_j) + tk_j + self.lin_k_edge(edge_attr)).view(-1, H, Dh)
        v = (self.lin_v(x_j) + tv_j + self.lin_v_edge(edge_attr)).view(-1, H, Dh)

        logits = (q * k).sum(dim=-1) / self.scale  # [E, H]

        # Directional bias: in flow matching all agents in a scene share the
        # same tau, so tau_i == tau_j and w_ij = sigmoid(0.5) β‰ˆ 0.62.
        w_ij = torch.sigmoid(
            self.attn_gamma * (tau_i - tau_j) + 0.5
        ).unsqueeze(-1)                                    # [E, 1]

        alpha = softmax(logits, index, ptr, size_i)        # [E, H]
        alpha = self.attn_drop(alpha) * w_ij               # [E, H]
        return v * alpha.unsqueeze(-1)                     # [E, H, Dh]

    def update(self, inputs):
        return self.out_proj(self.proj_drop(inputs.view(-1, self.embed_dim)))

    def _mha_block(self, x, edge_index, edge_attr, temb_agent, tau, size):
        return self.propagate(edge_index=edge_index, x=x, edge_attr=edge_attr,
                              temb_agent=temb_agent, tau=tau, size=size)

    def _ff_block(self, x):
        return self.mlp(x)


# ---------------------------------------------------------------------------
# Main module
# ---------------------------------------------------------------------------

class FutureInteractionGraph(nn.Module):
    """Augment per-agent embeddings with future Euclidean interaction context.

    Memory-efficient forward pass:
      1. Average y_abs over K modes β†’ y_abs_mean [B, A, T, 2].
         This gives one representative future trajectory per agent per scene.
      2. Build a B-scene fully-connected graph (E_b = B Γ— AΓ—(A-1) β‰ˆ 27.5 K).
      3. Compute the mean relative future position for each edge:
           rel_pos_mean[e] = mean_t( pos[j, t] - pos[i, t] )   (shape [E_b, 2])
         Then project to [E_b, D].  (No transformer over T; avoids the FFN
         memory bottleneck that caused OOM with 550 K-edge batches.)
      4. Expand edge features to all K modes: edge_attr_bk [B*K*E0, D] by
         repeating each scene's edge features K times.
      5. Run GNN on B*K*A = 55 K nodes with B*K*E0 = 550 K edges in one
         vectorised forward (peak ~3 GB with gradients β€” fits on A6000).
      6. Gated residual back into y_emb.
    """

    def __init__(self, embed_dim: int, future_steps: int, num_agents: int,
                 num_heads: int = 4, dropout: float = 0.1,
                 num_gnn_layers: int = 2, time_dim: int = 128):
        super().__init__()
        self.future_steps = future_steps
        self.num_agents   = num_agents
        self.embed_dim    = embed_dim
        self.time_dim     = time_dim
        self._E0          = num_agents * (num_agents - 1)  # edges per scene

        # Project mean relative position [E, 2] β†’ [E, D]
        self.rel_pos_proj = nn.Sequential(
            nn.Linear(2, embed_dim),
            nn.ReLU(inplace=True),
            nn.Linear(embed_dim, embed_dim),
        )

        # GNN layers (shared across all K modes / all scenes)
        self.gnn_layers = nn.ModuleList([
            FutureGeomAttnLayer(
                embed_dim=embed_dim, num_heads=num_heads,
                dropout=dropout, time_dim=time_dim,
            )
            for _ in range(num_gnn_layers)
        ])

        # Gated residual
        self.gate_proj = nn.Sequential(
            nn.Linear(embed_dim * 2, embed_dim),
            nn.Sigmoid(),
        )
        self.out_proj = nn.Linear(embed_dim, embed_dim)

        # Pre-built edge index for one fully-connected A-agent graph
        single_ei = self._make_single_edge_index(num_agents)
        self.register_buffer('_single_edge_index', single_ei)

    # ------------------------------------------------------------------
    # Graph construction helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _make_single_edge_index(A: int) -> torch.Tensor:
        """Fully-connected directed graph on A nodes: A*(A-1) edges.

        Convention:
          edge_index[0] = neighbor j  (source / sender)
          edge_index[1] = center  i   (target / receiver)
        β†’ PyG x_i = center, x_j = neighbor (source_to_target flow).
        """
        src, dst = [], []
        for i in range(A):      # center / target
            for j in range(A):  # neighbor / source
                if i != j:
                    src.append(j)
                    dst.append(i)
        return torch.tensor([src, dst], dtype=torch.long)

    def _make_batched_edge_index(self, num_scenes: int) -> torch.Tensor:
        """Stack num_scenes copies of the A-node graph with correct offsets.

        NOTE (edge-index scene-mixing bug):
          `batched` is [S, 2, E0]; its contiguous layout is
          s0_src, s0_dst, s1_src, s1_dst, ...  A direct `.reshape(2, -1)` therefore
          packs *scene blocks* into each row instead of the src/dst rows, so row 0
          ends up holding s0_src followed by s0_dst, etc.  Consequences (measured,
          A=11): 0% of edges stay inside a scene, exactly half the nodes receive no
          incoming edge at all, and the other half receive 2x the intended degree.
          The correct behaviour is to move the src/dst axis first via permute.

          SRA_EDGE_FIX=1 selects the correct per-scene graph.  The default keeps the
          original (buggy) behaviour so that previously trained checkpoints and any
          in-flight runs remain reproducible.  See models/graph_interaction_nba_v6.py
          (line ~171), which is the call site used by MID / LED / MoFlow.
        """
        single  = self._single_edge_index              # [2, E0]
        A       = self.num_agents
        offsets = torch.arange(num_scenes, device=single.device) * A  # [S]
        batched = (single.unsqueeze(0)
                         .expand(num_scenes, -1, -1)   # [S, 2, E0]
                   + offsets.view(-1, 1, 1))
        if os.environ.get('SRA_EDGE_FIX', '') not in ('', '0', 'false', 'False'):
            # [S, 2, E0] -> [2, S, E0] -> [2, S*E0]: keeps src/dst rows intact
            return batched.permute(1, 0, 2).reshape(2, -1)
        return batched.reshape(2, -1)                  # [2, S*E0]  (legacy, scene-mixing)

    # ------------------------------------------------------------------
    # Forward
    # ------------------------------------------------------------------

    def forward(
        self,
        y_emb: torch.Tensor,             # [B, K, A, D]
        y_abs: torch.Tensor,             # [B, K, A, T, 2]  absolute-ish future pos
        t_emb: torch.Tensor,             # [B, D]  time embedding from backbone
        tau:   torch.Tensor,             # [B] ∈ [0, 1]
        sigma_agent: torch.Tensor = None,# [B, K, A] per-agent uncertainty, or None
    ) -> torch.Tensor:                   # [B, K, A, D]
        B, K, A, D = y_emb.shape
        T  = y_abs.shape[3]
        E0 = self._E0              # A*(A-1) edges per scene

        # ---- Step 1: mode-averaged future positions ----------------------
        # [B, A, T, 2]  β€” representative trajectory per agent per scene
        y_abs_mean = y_abs.mean(dim=1)

        # ---- Step 2: B-scene edge index  (E_b = B * E0 β‰ˆ 27.5 K) -------
        edge_index_b = self._make_batched_edge_index(B)    # [2, B*E0]

        # ---- Step 3: mean relative position as edge feature  [E_b, D] ---
        pos_b = y_abs_mean.reshape(B * A, T, 2)            # [B*A, T, 2]
        # neighbor j = edge_index_b[0], center i = edge_index_b[1]
        rel_pos_mean = (pos_b[edge_index_b[0]] -
                        pos_b[edge_index_b[1]]).mean(dim=1) # [E_b, 2]
        edge_attr_b = self.rel_pos_proj(rel_pos_mean)       # [E_b, D]

        # ---- Step 4: expand edge features to B*K scenes  [B*K*E0, D] ---
        # Each scene b has K modes; all modes share the same edge features.
        edge_index_bk = self._make_batched_edge_index(B * K)  # [2, B*K*E0]

        # edge_attr_b [B*E0, D] β†’ view [B, E0, D] β†’ expand [B, K, E0, D]
        #  β†’ reshape [B*K*E0, D]
        edge_attr_bk = (edge_attr_b
                        .view(B, E0, D)
                        .unsqueeze(1)
                        .expand(-1, K, -1, -1)
                        .reshape(B * K * E0, D))             # [B*K*E0, D]

        # ---- Step 5: per-agent time / uncertainty embeddings -------------
        # t_emb: [B, D] β†’ expand to [B, K, A, D] β†’ flatten [B*K*A, D]
        temb_bka = (t_emb
                    .unsqueeze(1).unsqueeze(2)
                    .expand(-1, K, A, -1)
                    .reshape(B * K * A, D))                   # [B*K*A, D]

        # Directional weight signal for graph attention:
        #   - if sigma_agent provided: collapse to scalar per agent (mean over T
        #     if [B,K,A,T], or use directly if [B,K,A]).
        #   - else: fall back to scalar denoising tau (same for all agents).
        if sigma_agent is not None:
            s = sigma_agent.mean(dim=-1) if sigma_agent.dim() == 4 else sigma_agent
            node_tau = s.reshape(B * K * A)                   # [B*K*A]
        else:
            node_tau = (tau
                        .unsqueeze(1).unsqueeze(2)
                        .expand(-1, K, A)
                        .reshape(B * K * A))                  # [B*K*A]

        # ---- Step 6: GNN pass on B*K*A nodes  ----------------------------
        nodes = y_emb.reshape(B * K * A, D)                  # [B*K*A, D]
        for layer in self.gnn_layers:
            nodes = layer(nodes, edge_index_bk, edge_attr_bk,
                          temb_agent=temb_bka, tau=node_tau)  # [B*K*A, D]

        # ---- Step 7: gated residual  -------------------------------------
        orig  = y_emb.reshape(B * K * A, D)
        gate  = self.gate_proj(torch.cat([orig, nodes], dim=-1))  # [N, D]
        out   = orig + gate * self.out_proj(nodes)                # [N, D]

        return out.view(B, K, A, D)