We estimate a dynamic discrete-choice model (DDCM) by NFXP: an optimizer searches over parameters θ, and for each θ we solve the agent's full day by GPU-accelerated backward induction and compare it to observed travel diaries. To make the optimizer converge efficiently we need the gradient of the log-likelihood. No NFXP estimation run with the analytical gradient ever truly converged — the likelihood went flat after a few iterations and the parameter mu_home jammed against its bound every time. The cause was a wrong gradient. After eliminating a dozen suspects, we found it: the analytical gradient is exactly correct when processing one home-zone at a time and 38% wrong when batching multiple zones together on the Apple GPU (MPS) backend. The math, the features, and the recursion are all right; the defect is a backend implementation issue — most likely an int32 integer overflow at real graph scale, the same family as three prior MPS-specific failures in this codebase. CUDA has now been confirmed clean (batch=8 == batch=1 == FD, 0.0% difference), so the production estimation launches immediately with no workaround. The MPS issue is a local-development matter only.
The production estimator runs on a Linux server with a single NVIDIA GPU (32 GB), shared with other lab members. Queue times, memory limits, and long iteration times made local debugging painful. The Mac on the desk has Apple Silicon with large unified memory (192 GB shared CPU+GPU). Moving local development to MPS gave us zero queuing and no memory cliffs. The design contract was byte-compatibility: same model, same parameters, same answer on CUDA, MPS, and CPU.
Every analytical-gradient estimation run showed the same fingerprint:
mu_home slides to its lower bound and jams there.We spent a long time treating this as an optimization problem and an identification problem:
None helped, because the cause was never the landscape or the optimizer. When every knob changes nothing, the thing you're turning is not the problem.
Three layers of concealment:
A wrong gradient runs, produces a believable number, and silently points the optimizer the wrong direction. Indistinguishable from a hard landscape.
Fixing each one changed the error magnitude but not the sign. Looked like progress. The zone-batch bug hid under the noise of the others.
All measurement scripts used the default zone_batch_size=8. So all measurements included the corruption without knowing it. The CUDA tests used a 1-zone group, so batching couldn't even trigger.
Group group_4fc04e219219.pt (33 workers, 16 home zones), Person 0, parameter mu_home, warm-start θ₀. Everything fixed except zone_batch_size:
FD (ground truth) : +704.60 MPS zone_batch_size = 1 : +704.61 ( 0.0%) ✓ exact MPS zone_batch_size = 8 : +436.24 (38.1%) ✗ WRONG CPU zone_batch_size = 8 : +704.61 ( 0.0%) ✓ exact
CPU batch=8 is exact → the logic is correct. MPS batch=8 is 38% wrong → the MPS kernel is wrong. It is not a math error.
Because the production server runs CUDA (NVIDIA GPU), we immediately ran the same test there (workers group, 8 home zones, same script):
FD (ground truth) : +942.62 CUDA zone_batch_size = 1 : +942.68 ( 0.0%) ✓ exact CUDA zone_batch_size = 8 : +942.67 ( 0.0%) ✓ exact batch=8 vs batch=1: 0.0% difference — CUDA is FINE
zone_batch_size. The production analytical-gradient estimation can launch immediately.
Near a reasonable parameter value, the two halves of the gradient score nearly cancel:
A 38% error in the GV-part means the computed value is −14,900 instead of −24,260. The computed "net" becomes +23,090 − 14,900 = +8,190 — wrong sign. The optimizer then walks away from the optimum on mu_home, hits the bound, and jams. This near-cancellation is a structural property of any well-specified likelihood at a reasonable parameter point (near the optimum, obs_feat and GV-part cancel exactly, since ∂ℓ/∂θ = 0 is the first-order condition). The tighter the cancellation, the more sensitive the sign is to errors in GV.
When processing Z_b home zones simultaneously, the gradient accumulation uses a 3-D tensor operation over shape (N_states, Z_b, P_params):
# GV_batch: (N, Z_b, P) GV_dst = GV_batch[edge_tgt] # gather destinations GV_dst.add_(F_sl.unsqueeze(1)) # add features (broadcast over zones) GV_dst.mul_(policy_norm.unsqueeze(2)) # weight by policy (broadcast over params) GV_batch.index_add_(0, src_idx, GV_dst) # ← suspected op
Our first guess was that MPS computes index_add_ wrong on 3-D tensors. But micro-tests showed MPS handles all these operations correctly at small scale.
The corruption appears only at real graph scale. At timestep t with E edges, Z_b=8 zones, P=11 params, the flattened element count is:
This crosses 2³¹ ≈ 2.15 billion (the int32 limit) when E > ~24M edges per timestep. At Z_b=1 the count is 8× smaller — stays under the limit — which is exactly why batch=1 is exact and batch=8 is corrupted. CPU and CUDA use int64 indexing internally and never overflow. This is the same family as two prior MPS-specific failures in this codebase:
| Operation | MPS failure scale | Workaround applied |
|---|---|---|
torch.unique | >17M rows | CPU fallback |
torch.sort on int64 | — | CPU fallback |
| 3-D gradient accumulation | E×Z_b×P > 2.15B | Fix pending (edge-chunking) |
edge_chunk_size parameter already exists in the code. This is needed only for local MPS development convenience; the production CUDA run needs no fix.
The analytical gradient is the exact derivative of the same log-likelihood that FD approximates. The published literature is unambiguous about this (see §8 for references). The key results from the literature:
gradient_bi_and_obs_features.A correct analytical gradient must match FD to within ~0.1–1% (FD truncation + float32 noise floor). A 17–38% disagreement is unambiguously a bug. And at zone_batch_size=1 we measure 0.0% — FD-exact — confirming the math is right.
| Bug | Effect | Status |
|---|---|---|
| Zone-batch MPS 3-D accumulation int32-overflow at scale |
38% workers GV error → mu_home sign flip → every analytical-gradient run flat | Found. CUDA confirmed clean. MPS fix pending (non-blocking). |
Bug 1: work-schedule fallbackor (480, 1020) for non-workers |
+22% non-workers GV overcount | Fixed — commit 06161733 |
| Bug 1b: tp masks not mutually exclusive HOME+SHOP could both get tp credit on ties |
Double-attribution of arrival-activity gradient | Fixed — commit 06161733 |
Bug 1c: wrong arrival-step formulafloor(t/Δt)+ceil(TT/Δt) ≠ floor((t+TT)/Δt) |
tp argmax sampled wrong timestep | Fixed — commit 06161733 |
Bug 2: ws=None crashfloat(None[0]) for non-workers |
Crash; workaround introduced Bug 1 | Fixed — commit 06161733 |
Bug 3: V=−inf spurious policyposinf=0.0 in nan_to_num |
Minor GV noise at infeasible states | Fixed — commit 06161733 |
Chunked-path missing argsmu_shop/leis/home not passed |
Wrong GV when --edge-chunk-size set |
Fixed — commit 06161733 |
Autograd NameErrormu_work_d etc. not in _autograd_bi signature |
Crash when using autograd gradient | Fixed — commit 06161733 |
| Graph builder OOM Moving 1.86B edges to GPU at build time |
OOM during Phase 1A on 32 GB CUDA GPU | Fixed — commit 06161733 |
| Travel td/tp features missing on travel edges F=0 for activity params on 99.7% of edges |
Per-edge F wrong before May 31 fix | Fixed — commit 507e9aa5 |
zone_batch_size · MPS at zone_batch_size=1 · CPU at any zone_batch_size. All secondary bugs fixed. Workers Phase 1A graphs built on CUDA (STATE_DIM=7).
cuda_debug_fd2.py on workers multi-zone group at zone_batch_size=8. One final sanity check.--analytical-gradient estimation on CUDA, workers-only, L-BFGS-B, warm-start from best checkpoint.investigations/check_edge_chunk_fix.py.GV(batch=1) == GV(batch=N) on every device.The deepest lesson from this investigation: a wrong gradient masquerades as a hard optimization landscape. "Flat LL, parameter jammed at its bound" was interpreted as an identification or optimizer problem. It was a gradient bug. When every optimizer and warm-start behaves the same way, suspect the gradient. And the defense against silent correctness failures is always the same: an independent oracle (CPU / FD) and varying one knob at a time. The day we compared batch=1 vs batch=8 against FD, the problem resolved in a single line of output.
Supporting code & scripts:
investigations/check_gv_order.py ·
investigations/check_zone_batch_device.py ·
cuda_check_zone_batch.py ·
estimation/analytical_se.py