crowd-nav — human-response models for the Foresight controller
Trained weights for Foresight, a convex receding-horizon controller that predicts how each nearby pedestrian will respond to the robot's candidate plan, and then chooses that plan by exact convex optimization. These are the response models the controller calls; the planner is plain code (no learned policy, no RL).
Every file, and what we use it for
Two stacks live here and they never touch each other. The controller models (repo root) predict
how a person responds to the robot's plan — they are what the robot thinks with. The crowd
models (crowd_models/) predict where pedestrians go — we use them to be the crowd the robot
has to get through, so the robot is never evaluated against the same model family it plans with.
| file | what it is | trained on | how we use it |
|---|---|---|---|
linres_head.pt |
Linear-response operator — social-force prior + residual MLP + a rank-3 hypernetwork emitting a 16×16 operator | PeRoI robot–pedestrian recordings (synthetic pre-train, real fine-tune) | The robot's brain — headline. The planner asks it "if I follow this plan, how does each person move?" Because the answer is exactly affine in the plan, choosing the plan is one convex QP with an exact Jacobian. Best closed-loop numbers, ~17 ms. |
residual_predictor_k1.pt |
Social-force prior (k=1) + MLP correction, 21.5 k params | same | The robot's brain — safest variant. Same role, but the response is nonlinear, so the planner needs 3 SCP iterations to reach the same QP. Fewest collisions of any variant, and the model the public package ships. |
residual_predictor.pt |
The same net trained against a k=0 prior | same | Nothing — deprecated. Deployed at k=1, so its correction fixes an error that is not there. Kept only so the prior-mismatch ablation in the paper reproduces. |
picnn_disruption.pt |
Partially input-convex net (PICNN), convex in the action | same | Research only. A convex upper bound on the disruption cost, fed to the planner as cutting planes. The controller runs fine without it. |
S_best.pt |
S-learner (one net, treatment as an input feature) | same | Research only. Causal-inference baseline for the response-prediction study (ATE / CATE), not a controller component. |
T_no_robot_best.pt, T_stationary_robot_best.pt, T_moving_robot_best.pt |
T-learner — a separate net per robot condition | same | Research only. The other arm of that study. The moving_robot head is the thin one: a single recording backs it. |
crowd_models/phys.pt |
CV + learned residual, LSTM encoder + neighbour MLP, 35.8 k params | pooled ETH/UCY, 36,197 windows | The crowd. Steps every pedestrian in the crowd-behaviour comparison while a scripted robot crosses the corridor. |
crowd_models/slstm.pt |
Social-LSTM (Alahi et al., CVPR 2016), our re-implementation, 141 k params | same | The crowd. Same role — grid-pooled social coupling instead of the MLP. |
crowd_models/sgan.pt |
Social-GAN generator (Gupta et al., CVPR 2018), our re-implementation, 79.8 k params | same | The crowd. Same role — max-pooled social vector. |
crowd_models/{phys,slstm,sgan,tf}_jrdb.pt |
The same four architectures (tf = transformer residual, 518 k params) |
JRDB alone, 43,750 train windows | The real-data benchmark. Trained and tested on real robot-in-crowd data, to check whether learned prediction beats constant velocity where a robot is actually present. It barely does — see below. |
Which one should I use?
linres_head.pt unless you have a reason not to. It is a few centimetres less accurate as a
pure forecaster, but its prediction is exactly affine in the robot's planned displacement, so the
planner gets an exact constant Jacobian and the safety constraints stay linear — the whole plan
selection becomes a convex QP that solves in ~17 ms with a checkable feasibility certificate.
That trade wins where it counts: it leads our benchmark, and it is the only variant that crossed a
90-pedestrian oncoming corridor with zero contacts.
Measured results
Isaac Sim, 16 scenarios × 10 paired seeds (160 episodes per method), identical hardware:
| model | goal reach | hard collisions / ep | plan latency (mean) |
|---|---|---|---|
Foresight-LinRes (linres_head.pt) |
94.4 % | 0.013 | 16.6 ms |
Foresight-Residual (residual_predictor_k1.pt) |
90.0 % | 0.006 | 19.7 ms |
| Foresight-SFM (physics only, ablation) | 93.8 % | 0.013 | 17.2 ms |
| Foresight-CV (straight-line, ablation) | 90.0 % | 0.019 | 16.4 ms |
| SICNav [T-RO '24] (nonconvex bilevel MPC) | 89.2 % | 0.042 | 1843 ms |
Stress test — a 4.5 m × 105 m corridor with 90 pedestrians all walking against the robot and a continuous inflow so it never empties: LinRes reached the goal in 98.5 s with zero contacts; against pedestrian models it was never tuned on (SRFM, social-force) it reached in 85.0 s and 91.7 s, also contact-free.
Prediction accuracy on the held-out real split (factual ADE, metres — lower is better):
| model | no robot | stationary robot | moving robot |
|---|---|---|---|
| Constant velocity | 0.240 | 0.253 | 0.354 |
| Social force | 0.343 | 0.358 | 0.416 |
| Residual (k1) | 0.192 | 0.217 | 0.285 |
| Linear response | 0.204 | 0.248 | 0.319 |
Architecture
Both models are a physics prior plus a learned correction — small by design, because the real moving-robot data is scarce.
prior (no parameters, per pedestrian per 0.25 s step)
f_goal = (1.3·(g−p)/‖g−p‖ − v)/0.5
f_ped = Σ 2.0·exp(−d/0.4)·d̂ (d < 2.0 m)
f_robot = k·exp(−d/0.5)·d̂ (d < 3.0 m)
v ← clip(v + f·Δt, 1.8); p ← p + v·Δt
residual_predictor_k1.pt ŷ = prior(k=1) + f_θ(x)
f_θ : MLP 22 → 128 → 128 → 16 (21,520 params)
linres_head.pt ŷ = prior(k=0) + f_θ(x) + G_φ(x)·ΔR
f_θ : MLP 22 → 128 → 128 → 16 (21,520 params)
G_φ : MLP 22 → 64 → 96 (7,712 params) ⇒ rank-3 16×16 operator
G = Σ_{r=1..3} a_r b_rᵀ, ΔR = (R − 1⊗r₀)/5.0
⇒ ∂ŷ/∂R = G/5.0 — exact, constant, no differentiation needed
Input x (22-d, pedestrian local frame): 1 s of past positions (4 @ 0.25 s), velocity, goal
direction, robot relative position, a robot-nearby flag, the two nearest neighbours, and a 3-way
condition one-hot. Output: 8 × 2 future displacements — 2.0 s at 0.25 s.
Training: supervised (Adam, lr 1e-3, batch 128, ~25 epochs), synthetic pre-train then fine-tune on the real PeRoI robot–pedestrian recordings. No reinforcement learning anywhere in this stack. Both run in well under 1 ms per call on CPU.
Load
pip install huggingface_hub torch
hf download elmoghany/crowd-nav linres_head.pt residual_predictor_k1.pt --local-dir .
import torch
ck = torch.load("linres_head.pt", map_location="cpu")
ck["config"]
# {'in_dim': 22, 'horizon_dim': 16, 'rank': 3, 'hidden': 64, 'dr_scale': 5.0,
# 'sfm_k_robot': 0.0, 'convention': 'y = sfm(k=0) + f_resid(x_ctx) + head(x_ctx, dR)'}
ck["f_resid"], ck["head"] # two state_dicts
The config block is authoritative: sfm_k_robot must match the robot-force strength used in
your prior at deployment. Mismatching them is a real bug we shipped once — the residual then
corrects an error that is not there. residual_predictor.pt is the artifact of that mistake and is
kept only for reproducibility.
Running linres_head.pt with no repository
The checkpoint holds two state dicts and nothing else, so the model classes fit here. Copy this
block and it runs — no clone, no install beyond torch and huggingface_hub.
import torch, torch.nn as nn
from huggingface_hub import hf_hub_download
class ResidualMLP(nn.Module): # f_resid: 22 -> 128 -> 128 -> 16
def __init__(self, in_dim=22, out_dim=16, hidden=128):
super().__init__()
self.net = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU(),
nn.Linear(hidden, hidden), nn.ReLU(),
nn.Linear(hidden, out_dim))
def forward(self, x): return self.net(x)
class LinearResponseHead(nn.Module): # G(x) = sum_r a_r b_r^T, rank 3
def __init__(self, in_dim=22, horizon_dim=16, rank=3, hidden=64):
super().__init__()
self.horizon_dim, self.rank = horizon_dim, rank
self.net = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU(),
nn.Linear(hidden, 2 * rank * horizon_dim))
def _factors(self, x):
ab = self.net(x).view(-1, 2, self.rank, self.horizon_dim)
return ab[:, 0], ab[:, 1]
def forward(self, x, dR): # exactly linear in dR
a, b = self._factors(x)
return torch.einsum('brh,br->bh', a, torch.einsum('brj,bj->br', b, dR))
def operator(self, x): # G, the exact Jacobian d(response)/d(dR)
a, b = self._factors(x)
return torch.einsum('bri,brj->bij', a, b)
ck = torch.load(hf_hub_download("elmoghany/crowd-nav", "linres_head.pt"), map_location="cpu")
f_resid, head = ResidualMLP(), LinearResponseHead()
f_resid.load_state_dict(ck["f_resid"]); head.load_state_dict(ck["head"])
f_resid.eval(); head.eval()
# response to a planned robot displacement dR = (robot_future - robot_now) / 5.0
x, dR = torch.randn(4, 22), torch.randn(4, 16)
with torch.no_grad():
y = f_resid(x) + head(x, dR) # add your sfm(k=0) prior to get the forecast
G = head.operator(x) # (4, 16, 16); divide by 5.0 for metres
assert head(x, torch.zeros(4, 16)).abs().max() == 0 # no robot => no response
assert (torch.einsum('bij,bj->bi', G, dR) - head(x, dR)).abs().max() < 1e-5
The two assertions are the properties the convex planner is built on: a zero action produces an
exactly zero response (so the no-robot counterfactual is free), and operator() is the Jacobian
rather than an approximation of it — no autodiff at plan time.
Where the code lives
| what | where |
|---|---|
ResidualPredictor + the ready-to-run controller (residual_predictor_k1.pt) |
github.com/elmoghany/crowd-nav — one file, simulator-free, with a quickstart.py that fetches these weights and verifies the install in one command |
LinearResponseHead |
the block above; it is 25 lines, so it is reproduced here in full |
The convex-QP planner that turns linres_head.pt into a controller, the Isaac Sim benchmarks and the paper |
github.com/elmoghany/crowd-nav-legacy (private — request access) |
Note the gap this leaves: the public package ships the velocity-grid planner, so it loads
residual_predictor_k1.pt and not linres_head.pt. LinRes's advantage is that it makes plan
selection an exact convex QP, and that only pays off with the QP planner. Use linres_head.pt as a
response model anywhere; to reproduce the closed-loop numbers above you need the research stack.
Crowd models (crowd_models/) — the pedestrians, not the robot
Four architectures behind one interface, so they are directly comparable: each observes the target's heading-normalized step velocities plus its 8 nearest neighbours (the robot included, as one more neighbour) and predicts a residual on constant velocity. Rotation into the target's frame makes the error rotation-invariant, so canonical error equals world error.
| name | architecture | params |
|---|---|---|
phys |
LSTM over the observed velocities + a per-neighbour MLP, summed | 35,800 |
slstm |
Social-LSTM — grid pooling of neighbour hidden states | 141,442 |
sgan |
Social-GAN generator — LSTM encoder, max-pool social vector, LSTM decoder | 79,810 |
tf |
Transformer: self-attention over time + cross-attention to neighbours | 518,168 |
slstm and sgan are our re-implementations of the published architectures against this interface,
not the authors' released weights.
JRDB — trained and tested on real robot-in-crowd data
humanmodel/train_jrdb.py, JRDB alone (43,750 train / 6,923 test windows), 80 epochs AdamW.
Metres, lower is better:
| model | test ADE | test FDE | train ADE |
|---|---|---|---|
| Constant velocity (no learning) | 0.448 | 0.926 | — |
phys_jrdb.pt |
0.406 | 0.822 | 0.265 |
sgan_jrdb.pt |
0.407 | 0.829 | 0.246 |
tf_jrdb.pt |
0.411 | 0.836 | 0.201 |
slstm_jrdb.pt |
0.438 | 0.891 | 0.173 |
Read this honestly: 9 % over constant velocity is the whole prize, the 518 k-parameter transformer does not beat the 36 k-parameter one, and the train/test gap grows with capacity — the ordering on test is close to the reverse of the ordering on train. Pretrained Social-Transmotion scores 0.40 on the same split with its own evaluation script. This is the evidence behind the "real-data forecasting is a tie" limitation below, and the reason our contribution is a controller rather than a forecaster.
ETH/UCY — the crowd drivers
humanmodel/train_cb_models.py, all five ETH/UCY scenes pooled (36,197 windows), 60 epochs.
Recorded train-ADE: phys 0.418, slstm 0.273, sgan 0.404.
Those are in-sample numbers and are not accuracy claims. Pooling every scene was deliberate —
these weights exist to drive a crowd, and a model that has seen all five scenes makes a better
crowd — but it leaves no held-out set, so nothing here should be quoted as ETH/UCY performance. Use
the JRDB table above for that. (If you do benchmark on ETH/UCY, note that Social-GAN's <scene>/train
directory already contains the other four scenes; reading it as "train" while testing on the held-out
scene leaks the test fold and produces impossibly low error.)
Used by humanmodel/crowd_rollout.run_learned_model: an 8-frame history per pedestrian, a model step
every 0.4 s, the residual un-rotated into the world, and the crowd walks. The robot in those runs is
a scripted wall-bouncing zigzag — we model the humans, not the robot.
Why they are here. Learned crowds are the honest test of a controller that plans against its own learned response model: against pedestrians driven by these, the robot cannot be right by construction. The result that made us keep them is a negative one. Counting contacts with an oncoming robot over one long corridor traverse:
| crowd driven by | contacts |
|---|---|
| a force model with an explicit robot-response term | 13 |
| ORCA | 124 |
| pretrained Social-Transmotion (0.40 m ADE) | 143 |
phys.pt |
161 |
sgan.pt |
191 |
slstm.pt |
198 |
Every trajectory predictor here walks into the robot roughly ten times as often as a model that represents the robot explicitly — and the best forecaster is not the best avoider. Predicting where people walk is a different job from getting out of the way, which is the whole reason the controller stack models the response to the robot rather than the trajectory.
Honest limitations
- Real-data forecasting is a tie. On four real robot–pedestrian datasets (including JRDB), every learned model here ties the analytic baselines on factual accuracy. Our measured explanation: the counterfactual proxy those benchmarks rely on (constant-velocity extrapolation) correlates only r ≈ 0.30 with exact simulated counterfactuals — the target is mostly noise. The gains above are closed-loop gains, not forecasting gains.
- Compliance bias. The training recordings contain cooperative pedestrians. On deliberately stubborn crowds the k1 residual is the weakest learned variant; compliance-randomized training data is the known fix, not yet applied.
- Cross-dataset transfer. Moving
residual_predictor_k1.ptto JRDB over-predicts robot-induced deviation by ~6× (its k=1 prior is calibrated to our robot). It makes a controller conservative, not unsafe — it still threaded real JRDB crowds contact-free — but retunesfm_k_robotfor a different platform.