← Reports

Analytical Gradient Investigation

DDCM NFXP Estimation · June 1, 2026 · Azwan Nazamuddin & Makoto Chikaraishi Lab
✓ Root cause found — CUDA production path unblocked

Contents

  1. One-paragraph summary
  2. The story — why it was so hard to find
  3. The decisive evidence
  4. What the mechanism is
  5. Why the analytical gradient should equal FD
  6. All bugs found and fixed
  7. Current status and next steps
  8. References

1. One-Paragraph Summary

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.

2. The Story — Why It Was So Hard to Find

Why we have two devices

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.

The symptom

Every analytical-gradient estimation run showed the same fingerprint:

What we tried that didn't work

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.

Why the bug stayed invisible

Three layers of concealment:

① It doesn't crash

A wrong gradient runs, produces a believable number, and silently points the optimizer the wrong direction. Indistinguishable from a hard landscape.

② Tangled with five other real bugs

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.

③ Every diagnostic used batch=8

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.

The break

Late May 2026
Per-edge features confirmed correct
F/FD ratio = 1.000 for all edge types including travel (td/tp). Policy normalization exact. obs_feat within 2% of FD. Bug must be in the GV backward recursion.
May 31, 2026
Five secondary bugs fixed
Work-schedule fallback, tp mask exclusivity, arrival-step formula, missing args in chunked path, autograd NameError. All real, all fixed. GV error still ~16–22%.
June 1, 2026 (MPS)
The smoking gun — zone_batch_size=1 vs 8
Varying only batch size while holding everything else fixed: batch=1 is exact (0.0%), batch=8 is 38% wrong, CPU-batch=8 is exact. Logic correct, MPS kernel wrong.
June 1, 2026 (CUDA)
CUDA confirmed clean
cuda batch=8 == cuda batch=1 == FD, 0.0% difference. Bug is MPS-only. Production estimation unblocked.

3. The Decisive Evidence

🔬 The Smoking Gun — One Experiment, One Line of Output

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.

CUDA confirmation

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
Verdict The zone-batch bug is MPS-only. CUDA is confirmed clean at any zone_batch_size. The production analytical-gradient estimation can launch immediately.

Why a 38% GV error flips the sign of mu_home

Near a reasonable parameter value, the two halves of the gradient score nearly cancel:

mu_home: obs_feat ≈ +23,090 minus GV-part ≈ −24,260 → net ≈ −1,170

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.

4. What the Mechanism Is

First hypothesis: MPS operator bug

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.

Refined hypothesis: int32 overflow at scale (current best)

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:

E × Z_b × P → E × 8 × 11 = E × 88

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:

OperationMPS failure scaleWorkaround applied
torch.unique>17M rowsCPU fallback
torch.sort on int64—CPU fallback
3-D gradient accumulationE×Z_b×P > 2.15BFix pending (edge-chunking)
MPS fix — non-blocking, lower priority The fix for MPS is to edge-chunk each timestep so the per-chunk E stays under 2.15B / (Z_b × P). The 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.

5. Why the Analytical Gradient Should Equal FD

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:

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.

GV(s) = Σ_{a∈A(s)} P(a|s) · [ F(s,a) + GV( dst(s,a) ) ] ← GV backward recursion (§3c) score_n = obs_feat_n − GV(x₀_n) ← NFXP score (telescoped form)

6. All Bugs Found and Fixed

BugEffectStatus
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 fallback
or (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 formula
floor(t/Δt)+ceil(TT/Δt) ≠ floor((t+TT)/Δt)
tp argmax sampled wrong timestep Fixed — commit 06161733
Bug 2: ws=None crash
float(None[0]) for non-workers
Crash; workaround introduced Bug 1 Fixed — commit 06161733
Bug 3: V=−inf spurious policy
posinf=0.0 in nan_to_num
Minor GV noise at infeasible states Fixed — commit 06161733
Chunked-path missing args
mu_shop/leis/home not passed
Wrong GV when --edge-chunk-size set Fixed — commit 06161733
Autograd NameError
mu_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

7. Current Status and Next Steps

What is correct and ready right now Analytical gradient is exact (0.0% vs FD) on: CUDA at any 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).

Main track — production estimation

  1. ✅ [Done] CUDA zone-batch test — 0.0% diff. Bug is MPS-only.
  2. → [Next] Validate all 11 params with cuda_debug_fd2.py on workers multi-zone group at zone_batch_size=8. One final sanity check.
  3. → [Then] Launch full --analytical-gradient estimation on CUDA, workers-only, L-BFGS-B, warm-start from best checkpoint.
  4. → [After convergence] Compute BHHH standard errors and assess behavioral validity.

Side track — MPS local-dev fix (non-blocking)

  1. Confirm int32-overflow hypothesis with investigations/check_edge_chunk_fix.py.
  2. Apply edge-chunking so per-chunk size stays under 2.15B — keeps computation on GPU, minimal slowdown.
  3. Add regression guard: GV(batch=1) == GV(batch=N) on every device.

The key lesson

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.

8. References

  1. Rust, J. (1987). "Optimal Replacement of GMC Bus Engines." Econometrica 55(5):999–1033.
  2. Rust, J. (2000). NFXP Documentation Manual, Version 6. editorialexpress.com/jrust/nfxp.pdf
  3. Eberwein, C. & Ham, J. (2008). "Obtaining analytic derivatives for a popular discrete-choice dynamic programming model." Economics Letters 101(3):170–172. — Analytic likelihood derivatives at the same cost as the likelihood; value-function derivative via backward recursion in finite horizon.
  4. Aguirregabiria, V. & Mira, P. (2002). "Swapping the Nested Fixed Point Algorithm." Econometrica 70(4):1519–1543.
  5. Aguirregabiria, V. & Mira, P. (2010). "Dynamic discrete choice structural models: A survey." Journal of Econometrics 156(1):38–67. — Score in observed-minus-expected-feature form.
  6. Abbring, J. & Klein, T. Dynamic Discrete Choice teaching materials. ddc.abbring.org

Supporting code & scripts: investigations/check_gv_order.py · investigations/check_zone_batch_device.py · cuda_check_zone_batch.py · estimation/analytical_se.py