File size: 17,744 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 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 | """
FutureInteractionGraphV6 β RAG-style neighbor retrieval with sparse RelTrajEncoder.
Replaces V5's per-edge MLP scorer with a RAG-style query-key dot product:
Per-node (55K, computed once):
q_i = W_q([y0_emb_i, Ο_i]) β what agent i is looking for
k_j = W_k([y0_emb_j, Ο_j]) β what agent j offers
Per-edge (550K, cheap):
semantic_score = (q_i Β· k_j) / βD_s
geo_bias = geo_mlp([mean_rel, std_rel, min_dist, heading_diff_mean])
score = semantic_score + geo_bias
Top-N selection β RelTrajEncoder([rel_pos, heading_diff]) on selected edges.
Design rationale:
- Asymmetric W_q / W_k: "what to look for" β "how to present oneself"
- Uncertainty in query/key: uncertain agents learn to seek certain neighbors
through training, not hard-coded scaling
- Geometric features as additive re-ranking bias: cleanly separates
semantic retrieval from spatial prior
- Heading in geo_bias: converging agents are more likely to interact
- Per-node query/key is ~10x cheaper than per-edge MLP scorer in V5
Document content (unchanged from V5):
RelTrajEncoder([rel_pos(2), heading_diff(1)]) β [E_selected, D]
β GNN message passing on sparse graph.
"""
import os
import torch
import torch.nn as nn
from models.graph_interaction_nba_v4 import FutureInteractionGraphV4
from models.graph_interaction_nba_v3 import RelTrajEncoder
from models.graph_interaction_nba_v5 import _heading_diff
class FutureInteractionGraphV6(FutureInteractionGraphV4):
"""RAG-style sparse interaction graph.
Extra constructor kwargs (beyond V4):
y0_score_dim (int, default 32): dim of query/key embedding space.
"""
EDGE_MODES = {
'full': 3, # rel_pos(2) + heading_diff(1)
'dist_only': 1, # ||rel_pos||(1)
'relpos_only': 2, # rel_pos(2)
'heading_only': 1, # heading_diff(1)
'full_relvel': 5, # rel_pos(2) + heading_diff(1) + rel_vel(2)
'vel_only': 2, # rel_vel(2)
}
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,
top_n_neighbors: int = 5, rel_traj_hidden: int = 32,
y0_score_dim: int = 32, edge_mode: str = 'full',
neighbor_mode: str = 'rag'):
super().__init__(
embed_dim = embed_dim,
future_steps = future_steps,
num_agents = num_agents,
num_heads = num_heads,
dropout = dropout,
num_gnn_layers = num_gnn_layers,
time_dim = time_dim,
top_n_neighbors = top_n_neighbors,
rel_traj_hidden = rel_traj_hidden,
)
assert edge_mode in self.EDGE_MODES, f"Unknown edge_mode: {edge_mode}"
self.edge_mode = edge_mode
in_ch = self.EDGE_MODES[edge_mode]
assert neighbor_mode in ('rag', 'l2', 'semantic'), f"Unknown neighbor_mode: {neighbor_mode}"
self.neighbor_mode = neighbor_mode
self.y0_score_dim = y0_score_dim
self.scale = y0_score_dim ** -0.5
self._original_top_n = top_n_neighbors
# Per-node: project y_0_hat trajectory to scoring space
self.y0_score_proj = nn.Sequential(
nn.Linear(future_steps * 2, y0_score_dim),
nn.ReLU(inplace=True),
)
# Asymmetric query / key encoders (W_q β W_k)
self.W_q = nn.Linear(y0_score_dim + 1, y0_score_dim)
self.W_k = nn.Linear(y0_score_dim + 1, y0_score_dim)
# Per-edge geometric re-ranking bias
# Input: mean_rel(2) + std_rel(2) + min_dist(1) + heading_diff_mean(1) = 6
self.geo_mlp = nn.Sequential(
nn.Linear(6, 16),
nn.ReLU(inplace=True),
nn.Linear(16, 1),
)
del self.edge_scorer
self.rel_traj_encoder = RelTrajEncoder(
out_dim = embed_dim,
T = future_steps,
D_hidden = rel_traj_hidden,
num_heads = 4,
in_channels = in_ch,
)
# ---- SRA_SOFT_START: identity-at-init for the graph branch ----------
# By default V6 inherits a randomly-initialised out_proj and a sigmoid
# gate that starts around 0.5, so a randomly-initialised graph output is
# injected into the host from the very first step. With SRA_EDGE_FIX=1
# every node now receives its full neighbour set (previously half the
# nodes were orphans), which makes that initial shock large enough to
# destabilise one-step flow matching (MoFlow).
#
# MID does not suffer from this because it zero-inits its own
# graph_out_proj and opens a learnable, clamped gate over a warmup
# schedule. SRA_SOFT_START ports that recipe to V6:
# * out_proj zero-init -> graph contributes exactly 0 at step 0
# * gate bias -> large negative, so sigmoid(gate) starts near 0
# Both stay LEARNABLE, so unlike a fixed SRA_GATE_SCALE the graph can
# still grow to full strength during training.
if os.environ.get('SRA_SOFT_START', '') not in ('', '0', 'false', 'False'):
nn.init.zeros_(self.out_proj.weight)
nn.init.zeros_(self.out_proj.bias)
_gb = float(os.environ.get('SRA_GATE_BIAS', -4.0) or -4.0)
for _m in self.gate_proj.modules():
if isinstance(_m, nn.Linear):
nn.init.constant_(_m.bias, _gb) # sigmoid(-4) ~ 0.018
# ------------------------------------------------------------------
# Forward
# ------------------------------------------------------------------
def forward(
self,
y_emb: torch.Tensor, # [B, K, A, D]
y_abs: torch.Tensor, # [B, K, A, T, 2] β y_0_hat unnorm
t_emb: torch.Tensor, # [B, D]
tau: torch.Tensor, # [B] β [0, 1]
sigma_agent: torch.Tensor = None, # [B, K, A, T] or None
agent_mask: torch.Tensor = None, # [B, A] bool, True=real; for padded batches
) -> torch.Tensor: # [B, K, A, D]
B, K, A, D = y_emb.shape
T = y_abs.shape[3]
# Degenerate case: a single-agent scene has no edges β the graph is a
# no-op, so just return the input untouched.
if A <= 1:
return y_emb
# Variable-A support (e.g. SDD, ETH/UCY): the model is instantiated with
# a max num_agents (padding budget), but each batch may contain a smaller
# real A. Rebuild the graph skeleton on the fly when A changes.
if A != self.num_agents:
self.num_agents = A
self._E0 = A * (A - 1)
self.top_n = max(1, min(self._original_top_n, A - 1))
src, dst = [], []
for i in range(A):
for j in range(A):
if i != j:
src.append(j); dst.append(i)
self._single_edge_index = torch.tensor(
[src, dst], dtype=torch.long, device=y_emb.device
)
E0 = self._E0
N = self.top_n
# ---- Per-node: y_0_hat trajectory embedding ---------------------
y0_flat = y_abs.reshape(B * K * A, T * 2) # [B*K*A, T*2]
y0_emb = self.y0_score_proj(y0_flat) # [B*K*A, D_s]
# ---- Per-node: uncertainty scalar -------------------------------
if sigma_agent is not None:
sigma_mean = sigma_agent.mean(dim=-1).reshape(B * K * A, 1) # [B*K*A, 1]
tau_bka = sigma_mean.squeeze(-1)
else:
sigma_mean = torch.zeros(B * K * A, 1, device=y_abs.device)
tau_bka = (tau
.unsqueeze(1).unsqueeze(2)
.expand(-1, K, A)
.reshape(B * K * A))
# ---- Per-node: query and key ------------------------------------
node_feat = torch.cat([y0_emb, sigma_mean], dim=-1) # [B*K*A, D_s+1]
q_bka = self.W_q(node_feat) # [B*K*A, D_s]
k_bka = self.W_k(node_feat) # [B*K*A, D_s]
# ---- Per-edge: build positions and relative features ------------
pos_bk = y_abs.reshape(B * K * A, T, 2)
edge_index_bk = self._make_batched_edge_index(B * K) # [2, B*K*E0]
pos_i_t = pos_bk[edge_index_bk[1]] # [E, T, 2]
pos_j_t = pos_bk[edge_index_bk[0]] # [E, T, 2]
rel_pos_t = pos_j_t - pos_i_t # [E, T, 2]
mean_rel = rel_pos_t.mean(dim=1) # [E, 2]
std_rel = rel_pos_t.std(dim=1) # [E, 2]
min_dist = rel_pos_t.norm(dim=-1).min(dim=1).values.unsqueeze(-1) # [E, 1]
# Mean heading diff over T as geometric feature
heading_full = _heading_diff(pos_i_t, pos_j_t) # [E, T, 1]
heading_mean = heading_full.mean(dim=1) # [E, 1]
# ---- Per-edge scoring -----------------------------------------------
if self.neighbor_mode == 'l2':
# L2 distance: select closest neighbors by avg future distance
avg_dist = rel_pos_t.norm(dim=-1).mean(dim=-1) # [E]
scores = -avg_dist # negate so topk picks smallest distance
elif self.neighbor_mode == 'semantic':
# Semantic-only: learned query-key dot product, no geo bias
q_i = q_bka[edge_index_bk[1]] # [E, D_s]
k_j = k_bka[edge_index_bk[0]] # [E, D_s]
scores = (q_i * k_j).sum(dim=-1) * self.scale # [E]
else:
# RAG: semantic dot product + geometric bias
q_i = q_bka[edge_index_bk[1]] # [E, D_s]
k_j = k_bka[edge_index_bk[0]] # [E, D_s]
semantic_score = (q_i * k_j).sum(dim=-1) * self.scale # [E]
geo_feat = torch.cat([mean_rel, std_rel,
min_dist, heading_mean], dim=-1) # [E, 6]
geo_bias = self.geo_mlp(geo_feat).squeeze(-1) # [E]
scores = semantic_score + geo_bias # [E]
# ---- Mask padded agents so top-N never selects them --------------
if agent_mask is not None:
# Build per-edge "both endpoints real" mask
# agent_mask: [B, A] -> broadcast to [B, K, A] for node-ness
node_real = agent_mask.unsqueeze(1).expand(B, K, A).reshape(B * K * A) # [B*K*A]
# edge_index_bk has shape [2, B*K*E0], rows 0=src=j, 1=dst=i
src_real = node_real[edge_index_bk[0]] # [E]
dst_real = node_real[edge_index_bk[1]] # [E]
edge_real = src_real & dst_real # [E]
scores = scores.masked_fill(~edge_real, float('-inf'))
# ---- Top-N selection per target agent ---------------------------
scores_grouped = scores.view(B * K * A, A - 1)
# Cap N so we never ask for more neighbors than rows.
N_use = min(N, A - 1)
_, top_idx = scores_grouped.topk(N_use, dim=-1, sorted=False)
mask = torch.zeros(B * K * A, A - 1,
device=scores.device, dtype=torch.bool)
mask.scatter_(1, top_idx, True)
mask_flat = mask.view(-1) # [B*K*E0]
# When padding is present, selected edges whose target agent is padded
# produce zero messages anyway; additionally drop any edge whose
# endpoint is padded so the GNN sees only real edges.
if agent_mask is not None:
mask_flat = mask_flat & edge_real # [B*K*E0]
# ---- RelTrajEncoder on selected edges only ----------------------
rel_pos_sel = rel_pos_t[mask_flat] # [E_sel, T, 2]
heading_sel = heading_full[mask_flat] # [E_sel, T, 1]
if self.edge_mode == 'full':
encoder_input = torch.cat([rel_pos_sel, heading_sel], dim=-1) # [E_sel, T, 3]
elif self.edge_mode == 'dist_only':
encoder_input = rel_pos_sel.norm(dim=-1, keepdim=True) # [E_sel, T, 1]
elif self.edge_mode == 'relpos_only':
encoder_input = rel_pos_sel # [E_sel, T, 2]
elif self.edge_mode == 'heading_only':
encoder_input = heading_sel # [E_sel, T, 1]
elif self.edge_mode == 'full_relvel':
rel_vel = rel_pos_sel[:, 1:] - rel_pos_sel[:, :-1] # [E_sel, T-1, 2]
rel_vel = torch.cat([rel_vel, rel_vel[:, -1:]], dim=1) # [E_sel, T, 2]
encoder_input = torch.cat([rel_pos_sel, heading_sel, rel_vel], dim=-1) # [E_sel, T, 5]
elif self.edge_mode == 'vel_only':
rel_vel = rel_pos_sel[:, 1:] - rel_pos_sel[:, :-1] # [E_sel, T-1, 2]
rel_vel = torch.cat([rel_vel, rel_vel[:, -1:]], dim=1) # [E_sel, T, 2]
encoder_input = rel_vel
if sigma_agent is not None:
sigma_full = sigma_agent.reshape(B * K * A, T)
sigma_i_t = sigma_full[edge_index_bk[1][mask_flat]]
sigma_j_t = sigma_full[edge_index_bk[0][mask_flat]]
sigma_bias = sigma_i_t - sigma_j_t # [E_sel, T]
else:
sigma_bias = None
edge_attr_sparse = self.rel_traj_encoder(
encoder_input, sigma_bias
) # [E_sel, D]
# ---- Sparse GNN pass --------------------------------------------
edge_index_sparse = edge_index_bk[:, mask_flat]
temb_bka = (t_emb
.unsqueeze(1).unsqueeze(2)
.expand(-1, K, A, -1)
.reshape(B * K * A, D))
nodes = y_emb.reshape(B * K * A, D)
for layer in self.gnn_layers:
nodes = layer(nodes, edge_index_sparse, edge_attr_sparse,
temb_agent=temb_bka, tau=tau_bka)
# ---- Gated residual ---------------------------------------------
orig = y_emb.reshape(B * K * A, D)
gate = self.gate_proj(torch.cat([orig, nodes], dim=-1))
# SRA_GATE_SCALE: global damping on the graph residual (default 1.0 = off).
_gs = float(os.environ.get('SRA_GATE_SCALE', 1.0) or 1.0)
res = _gs * gate * self.out_proj(nodes) # [N, D] graph perturbation
# SRA_RES_CAP: per-node residual-norm cap (default 0 = off). With
# SRA_EDGE_FIX=1 training is healthy (loss decreases monotonically) but
# *sampling* diverges: the graph is applied at every one of MoFlow's flow
# steps and its output feeds the next step, so any oversized per-node
# perturbation compounds geometrically over the integration. The old
# scene-mixing bug hid this by leaving half the nodes orphaned (weaker
# perturbation, less compounding). Capping each node's residual NORM
# bounds the per-step perturbation that drives the blow-up, while leaving
# the (majority) small perturbations untouched β unlike a global scale it
# only clips the outliers, so the graph keeps its normal expressive range.
# μ£Όμ: μμ ꡬν `res * (rn.clamp(max=cap) / (rn + 1e-6))` μ λ κ°μ§κ° νλ Έλ€.
# (1) res κ° μ νν 0 μ΄λ©΄ μ€μΌμΌμ΄ 0/1e-6 = 0 μ΄ λμ΄ **gradient λ 0** μ΄λ€.
# SRA_SOFT_START(out_proj zero-init) μ κ°μ΄ μΌλ©΄ out_proj κ° 0 μ μꡬν
# κ°ν κ·Έλνκ° νμ΅λμ§ μλλ€(=μ¬μ€μ host λ¨λ
). μ€μ λ‘ κ·Έ μ‘°ν©μΌλ‘
# λλ¦° μ€νλ€μ out_proj λ 58 epoch λ€μλ μ νν 0 μ΄μλ€.
# (2) cap λ―Έλ§μΈλ°λ rn/(rn+1e-6) λ§νΌ μΆμλλ€ (rn=1e-5 μ΄λ©΄ 0.909 λ°°).
# torch.where λ‘ λ°κΎΈλ©΄ cap μ΄νλ μ νν 무μ°μ°(μ€μΌμΌ 1)μ΄κ³ res=0 μμλ
# gradient κ° νλ₯Έλ€. clamp_min μ λ―Έμ ν λΆκΈ°μ Inf λ₯Ό λ§λλ€.
_cap = float(os.environ.get('SRA_RES_CAP', 0.0) or 0.0)
if _cap > 0:
rn = res.norm(dim=-1, keepdim=True) # [N, 1]
res = res * torch.where(rn > _cap, _cap / rn.clamp_min(1e-6),
torch.ones_like(rn))
# SRA_RES_CAP_REL: λ
Έλλ³ μνμ **νΈμ€νΈ μλ² λ© norm μ λΉλ‘**ν΄ μ νλ€.
# μ λ cap μ μλ² λ© μ€μΌμΌμ μμ‘΄ν΄ νΈμ€νΈλ§λ€ μλ―Έκ° λ¬λΌμ§λ€(MoFlow μμ
# νλν 3.0 μ΄ MID μμλ μ¬μ€μ 무μ°μ°μΌ μ μλ€). μνλ§ λ°μ°μ κ²°κ΅
# "μ€ν
λΉ μλ μλ"μ΄ λμ λλ λ¬Έμ μ΄λ―λ‘, βresβ β€ ratioΒ·βorigβ λ‘ λλ©΄
# μ€μΌμΌ 무κ΄νκ² λμ λ₯ μ μ§μ μ ννλ€. μΈ‘μ κ° κΈ°μ€ λ¬΄μ ν μ λΉμ¨μ ~0.39.
_rel = float(os.environ.get('SRA_RES_CAP_REL', 0.0) or 0.0)
if _rel > 0:
lim = _rel * orig.norm(dim=-1, keepdim=True) # [N, 1] λ
Έλλ³ μν
rn = res.norm(dim=-1, keepdim=True)
res = res * torch.where(rn > lim, lim / rn.clamp_min(1e-6),
torch.ones_like(rn))
out = orig + res
return out.view(B, K, A, D)
|