feat: MultiStartGradient value/gradient NaN step diagnostics - #1473
Merged
Conversation
…dient The fit loop detected a dead lane only via the VALUE (`alive = np.isfinite(foms)`). A lane whose likelihood is finite but whose gradient is non-finite was counted alive, had its update zeroed by `apply_if_finite`, and silently froze in place -- a differentiability failure that looked exactly like convergence in the figure-of-merit trace. Count both failure modes per step, disjointly (gradient-NaN only for lanes still alive by value), persist them into `search_internal` and `samples_info`, and surface them in `search.summary` with rates normalised by lane-steps. Gradient finiteness is reduced INSIDE the jitted call, as a third output. Reducing eagerly outside the jit measured ~+3%/step against a ~1.5% noise floor -- worse than pulling the whole gradient to host, which it was meant to avoid -- because an un-jitted reduction costs a kernel dispatch plus a host round-trip. Fused it is +0.05%, below noise. Measurement only: `resurrect` still triggers on value-NaN alone, so existing benchmark numbers stay comparable. The resurrection policy is decided after the counters show how often frozen lanes actually occur. The counting helper is pure NumPy and separately testable, keeping JAX out of the library unit suite. `search_summary_from_samples` is on every search's summary path, so the new block is guarded on the key: Nautilus and friends, which have no gradient to be non-finite, emit exactly what they did before. Refs #1472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MultiStartGradientdetected a dead lane only via the value (alive = np.isfinite(np.asarray(foms))). The gradient was never checked inside the fit loop, so a lane whose likelihood is finite but whose gradient is non-finite was counted alive, had its update zeroed byoptax.apply_if_finite, and silently froze in place — a differentiability failure that looks exactly like convergence in the figure-of-merit trace.This counts both failure modes per step, disjointly, and surfaces them:
Gradient finiteness is reduced inside the jitted call, as a third output. Reducing eagerly outside the jit measured ~+3%/step against a ~1.5% noise floor — worse than pulling the whole gradient to host, which it was meant to avoid — because an un-jitted reduction costs a kernel dispatch plus a host round-trip. On a real MGE lens likelihood the shipped variant costs 4.1 µs on a 1.03 s step = 0.0004% of run time (measured in PyAutoLabs/autolens_profiling#PENDING).
Measurement only.
resurrectstill triggers on value-NaN alone, so every existing benchmark number stays comparable. The resurrection policy is decided after the counters show how often frozen lanes actually occur.Closes #1472
API Changes
None — internal changes only.
Two additive
samples_infokeys (n_value_nan_lane_steps,n_grad_nan_lane_steps) and extrasearch.summarylines for gradient searches. No public symbol added, removed, renamed, or re-signatured.search_summary_from_samplesis on every search's summary path, so the new block is guarded on the key — searches without gradients (Nautilus) emit byte-identical output to before.See full details below.
Test Plan
_nan_lane_countsunit-tested (pure NumPy, no JAX in the library suite), including both-NaN-on-the-same-lane disjointness and integer-dtypegrad_finitesamples_infoplumbing tested via hand-builtsearch_internal, including a legacy dict with neither key →0batch_sizeNone/2×resurrectoff/on); withresurrect=Truethe value-NaN count equalsn_resurrectionsexactlyjnp.whereAD trap → 14/80 lane-steps counted,n_resurrectionsunchanged, confirming frozen lanes are counted but deliberately not resurrectedsearch.summaryverified on disk (not just in-process), with rates checked againstcount / (n_starts × total_steps)read off the filealive &fails exactly the same-lane cases; ungating the summary block fails the Nautilus test and the pre-existingtest__search_summary_to_fileKnown coverage gap
Resume accumulation is covered only by unit tests over hand-built
search_internaldicts. Demonstrating it end-to-end requires resuming a killed mid-run search, which fails onmainfor unrelated reasons: the FoM sanity check compares a stored log-likelihood against the multi-start chi-squared convention (a consistent −2× relationship). Reproduced onmainwith the stockaf.ex.Analysis, so it predates this PR. Filed asPyAutoMind/draft/bug/autofit/multistart_gradient_resume_fom_sanity_check.md; re-check the counters here once it is fixed.Full API Changes (for automation & release notes)
Removed
Added
samples_info["n_value_nan_lane_steps"]— lane-steps where the likelihood was undefinedsamples_info["n_grad_nan_lane_steps"]— lane-steps where the likelihood was defined but not differentiablesearch_internal["n_value_nan_lane_steps"]/search_internal["n_grad_nan_lane_steps"]— persisted, resume-safe via.get(..., 0)Changed Behaviour
search.summarygainsResurrections,Value-NaN Lane-Steps,Gradient-NaN Lane-Stepsand two normalised rates for gradient searches only. Guarded on thesamples_infokey, so all other searches are unaffected.MultiStartGradient's internal jitted objective returns a third output (per-start gradient finiteness). Internal to_fit; the single-point objective used for the initial draw is deliberately left as a 2-tuple.Migration
0when absent, so pre-existingsearch_internalfiles load unchanged.Generated by the PyAutoLabs agent workflow.