sra-trajectory-code / MoFlow /models /graph_interaction_nba_v6.py
po03087's picture
Fix edge-index scene mixing; add relative residual cap; guard LED sigma NaN
37c61d4 verified
Raw
History Blame Contribute Delete
17.7 kB
"""
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)