Skip to content

fix: correct Geometric::inverse_cdf against its definition#411

Open
agene0001 wants to merge 2 commits into
statrs-dev:mainfrom
agene0001:fix/342-geometric-inverse-cdf
Open

fix: correct Geometric::inverse_cdf against its definition#411
agene0001 wants to merge 2 commits into
statrs-dev:mainfrom
agene0001:fix/342-geometric-inverse-cdf

Conversation

@agene0001

Copy link
Copy Markdown

Stacked on #410 — contains its commit; review only the last commit until that merges.

Geometric overrides inverse_cdf with the closed form ceil(ln1p(-x) / ln1p(-p)). The quotient is only approximately integral at the step boundaries, so ceil lands on either side of the true one: over a sweep of 200 values of p × 400 of k, 7255 of 16200 pairs had inverse_cdf(cdf(k)) != k.

Being an override, this was unreachable by the improvements to the default inverse_cdf (#390, #391) — which is why #342 stayed open after the explicit implementation it asks for had landed.

The closed form is now corrected against the definition being inverted — the least k with cdf(k) >= x:

  • ordinary rounding puts it within one step, so the fast path is two or three cdf evaluations (8 → 23 ns/call);
  • once x is within a few ulp of 1, 1 − x has lost every significant bit and the closed form can be off without bound (cdf has a plateau ~1/p wide there; at p = 1e-9 the correct answer is 36331335444), so it falls back to a bisection that is exact by definition.

After: 0 genuine round-trip failures (the 402 remaining are cases where cdf(k-1) == cdf(k) in f64, so no integer is recoverable), and 0 violations of the definition over 30266 inputs including x within ulps of 0 and 1.

This also un-ignores test_inverse_cdf_small_p and test_inverse_cdf_extreme_tail (#[ignore = "Gaps in pathological corner cases and testing"]). The former was masking two independent bugs: it constructs p = 1 − 1e-14, which the old ulps_eq! epsilon (#410) snapped to the degenerate p == 1 branch, returning 1 instead of u64::MAX. It needs both fixes — hence the stacking.

Closes #342

🤖 Generated with Claude Code

`approx`'s `ulps_eq` short-circuits on `abs_diff_eq(epsilon)` before it
consults the ULPs distance. The crate's wrapper paired it with
`DEFAULT_EPS = 1e-9`, which made the ULPs bound unreachable, so every internal
`ulps_eq!(x, y)` was really a 1e-9 absolute comparison.

That matters because the macro is used to recognise *exact* parameter values, so
anything within 1e-9 of them silently took a degenerate branch:

    Binomial(p = 1 - 2^-33, n = 100).pmf(99)   0    (true 1.16e-8)
    Binomial(p = 1 - 2^-33, n = 100).ln_pmf(99) -inf
    Geometric(p = 1 - 2^-33).max()             1    (true u64::MAX)
    Geometric(p = 1 - 2^-33).skewness()        inf  (true 92681.9)
    Beta(1 + 2^-33, 1 + 2^-33).pdf(0.0)        1    (true 0)
    digamma(-1 + 2^-33)                        -inf (true -8.59e9)

Adds `DEFAULT_ULPS_EPS = f64::EPSILON` and uses it for `ulps_eq!` and
`assert_ulps_eq!`. The exact values still take the degenerate branches -
`Binomial(1.0, 5).pmf(5) == 1`, `Geometric(1.0).max() == 1`, `digamma` at the
poles is still -inf - all covered by existing tests.

Tests use `1 - 2^-33` rather than `1 - 1e-10` so that `1 - p` is exact and the
references are not limited by the representation of `p`.

Also fixes `Binomial::entropy`, which summed `-p * ln(p)` unguarded and so
returned `NaN` once any mass underflowed to zero (`0 * -inf`) - for `p = 0.5`
that is every `n` past about 1100. `Categorical::entropy` already filtered zero
terms; this brings `Binomial` in line.
`Geometric` overrides `inverse_cdf` with the closed form
`ceil(ln1p(-x) / ln1p(-p))`. The quotient is only *approximately* integral at
the step boundaries, so `ceil` lands on either side of the true one and the
round-trip fails for most `p`: over a sweep of 200 values of `p` and 400 of
`k`, 7255 of 16200 `(p, k)` pairs had `inverse_cdf(cdf(k)) != k`.

Being an override, this was untouched by the work on the default
`inverse_cdf` (statrs-dev#390, statrs-dev#391), which is why statrs-dev#342 stayed open after the explicit
implementation it asks for had landed.

The closed form is now corrected against the definition being inverted - the
least `k` with `cdf(k) >= x`:

  * ordinary rounding puts it within one step, so the fast path is two or three
    `cdf` evaluations;
  * once `x` is within a few ulp of 1, `1 - x` has lost every significant bit
    and the closed form can be off without bound (`cdf` has a plateau roughly
    `1/p` wide there - at `p = 1e-9` the answer is 36331335444), so it falls
    back to a bisection that is exact by definition.

After: 0 genuine round-trip failures (the 402 remaining are all cases where
`cdf(k-1) == cdf(k)` in f64, so no integer is recoverable), and 0 violations of
the definition over 30266 inputs including `x` within ulps of 0 and 1.

This also un-ignores `test_inverse_cdf_small_p` and
`test_inverse_cdf_extreme_tail`, marked `#[ignore = "Gaps in pathological
corner cases and testing"]`. The former was masking two separate bugs: it needs
both this fix and the `ulps_eq!` epsilon fix, because it constructs
`p = 1 - 1e-14`, which the old 1e-9 epsilon snapped to the degenerate `p == 1`
branch.

Cost: 8 -> 23 ns/call for the verifying `cdf` evaluations.

Closes statrs-dev#342
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.

issue in default inverse_cdf, found with simple case of Geometric

1 participant