Skip to content

fix: safeguard Gamma::inverse_cdf Newton step to avoid NaN for shape < 1 - #382

Merged
YeungOnion merged 2 commits into
statrs-dev:mainfrom
gaoflow:fix-gamma-inverse-cdf-newton-overshoot
Jul 18, 2026
Merged

fix: safeguard Gamma::inverse_cdf Newton step to avoid NaN for shape < 1#382
YeungOnion merged 2 commits into
statrs-dev:mainfrom
gaoflow:fix-gamma-inverse-cdf-newton-overshoot

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Gamma::inverse_cdf(p) returns NaN for any shape < 1 once p is small enough. ChiSquared (df < 2) and Erlang delegate to the same solver and inherit it — this is the NaN reported in #361 and #373.

Gamma::new(0.5, 1.0).unwrap().inverse_cdf(0.01);    // NaN — scipy: 7.8544e-5
Gamma::new(0.18, 0.18).unwrap().inverse_cdf(0.25);  // NaN — scipy: 1.6167e-3   (#373)
ChiSquared::new(1.0).unwrap().inverse_cdf(0.01);    // NaN — scipy: 1.5709e-4   (#361)

Root cause

inverse_cdf builds a valid bracket [low, high] by bisection and then refines it with an unguarded Newton–Raphson loop. For shape < 1 the true quantile sits extremely close to 0 while the post-bisection iterate is still far from it, so the first Newton step overshoots to x < 0, outside the support. There cdf(x) == 0 and pdf(x) == 0, so the update is (0 - p) / 0 = -inf; the next step is inf - inf = NaN, which propagates because the loop never re-brackets. The bracket invariant from the bisection phase is discarded by the Newton loop.

Fix

Merge the two phases into one safeguarded loop (Numerical Recipes' rtsafe): keep [low, high] as an invariant, tighten it by the sign of cdf(x) - p each step, and accept a Newton step only when it is finite and strictly inside the bracket, otherwise take a bisection step. The iterate can no longer leave the support, so the result is always finite; shape ≥ 1 results are unchanged.

I diff-tested the whole inverse_cdf surface against scipy.stats / mpmath (dps=60) to scope this: the Gamma family (Gamma/ChiSquared/Erlang) was the only one producing NaN. Beta/StudentsT/FisherSnedecor go through the separately-safeguarded AS 64 inv_beta_reg, and Chi/InverseGamma use the bracketed default search — none share this failure — so the fix stays in the Gamma solver.

Tests

Added regression tests in gamma.rs (all NaN/panic before the fix): shape < 1 quantiles are finite; values checked against scipy/mpmath constants; round-trip cdf(inverse_cdf(p)) ≈ p and monotonicity across the shape/p grid; and ChiSquared/Erlang delegation, including the large-df upper-tail case from #361. Full test suite, cargo fmt --check and cargo clippy are clean.

gaoflow and others added 2 commits July 16, 2026 08:54
inverse_cdf refined its bisection bracket with an unguarded Newton–Raphson
loop. For shape < 1 the quantile lies very close to 0, so the first Newton
step overshoots below the support (where pdf == 0), the division blows up
and the iteration diverges to NaN. ChiSquared (df < 2) and Erlang delegate
to this solver and were affected too.

Keep the bracket as an invariant through the Newton loop (rtsafe): take a
Newton step only when it is finite and strictly inside [low, high],
otherwise bisect. The iterate can no longer leave the support.

Fixes statrs-dev#373 and statrs-dev#361.
@YeungOnion

Copy link
Copy Markdown
Contributor

I like the solution, we might benefit from this kind of search for other inverse cdf steps for float domains. Thanks for this!

@YeungOnion
YeungOnion merged commit 12eee33 into statrs-dev:main Jul 18, 2026
3 of 6 checks passed
YeungOnion pushed a commit that referenced this pull request Jul 27, 2026
Chi and InverseGamma were the only continuous distributions still using
the generic bisection default for inverse_cdf. Give them a custom solver
in the same brent-like + Newton-Raphson vein as Gamma (#382): a shared
internal::newton_raphson_quantile that brackets the quantile to a factor
of two and refines it with safeguarded Newton steps, falling back to
bisection whenever a step is non-finite or leaves the bracket.

Two refinements over a plain port keep it accurate and fast across the
whole range, including the tails #390 cared about:

- convergence is tested on the relative step, not prec::convergence's
  absolute 1e-9 (meaningless for a 1e-12 quantile); and the Newton step
  is checked for convergence before the bracket is tightened, so a
  converged step that rounds onto an endpoint is not rejected into a
  spurious bisection.
- the upper half inverts sf rather than cdf, which saturates to one and
  loses the resolution to place a deep upper-tail quantile.

Matches scipy/mpmath (dps=60) to <= 3.4e-15 relative error over
p in [1e-12, 1 - 1e-12] for both distributions, and converges in a
handful of Newton steps (medians ~5, vs ~48 fixed bisection steps),
roughly 60% fewer cdf/pdf evaluations across the grid.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants