Skip to content

Implement icdf for Poisson, Binomial, and NegativeBinomial#8339

Open
jacobpstein wants to merge 4 commits into
pymc-devs:mainfrom
jacobpstein:add-icdf-discrete-distributions
Open

Implement icdf for Poisson, Binomial, and NegativeBinomial#8339
jacobpstein wants to merge 4 commits into
pymc-devs:mainfrom
jacobpstein:add-icdf-discrete-distributions

Conversation

@jacobpstein

@jacobpstein jacobpstein commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Description

Adds icdf (quantile / inverse-CDF) methods for four discrete distributions:

  • Bernoulli — closed form, icdf(q) = 0 if q ≤ 1 − p else 1.
  • Poisson, Binomial, NegativeBinomial — these have no closed-form quantile, and the special-function inverses (gammaincinv/betaincinv) don't apply since they invert the CDF in its continuous probability argument rather than over the integer support. This adds a shared discrete_icdf_via_search helper (in dist_math.py) that inverts the monotone logcdf by bisection, with geometric bracket expansion for the unbounded-support cases (Poisson, NegativeBinomial).

The helper uses fixed-length scan loops (no data-dependent until) so the graph remains convertible to the C, Numba, and JAX backends — verified for all three. As a side effect this enables pm.Truncated for these distributions, which falls back to icdf for inverse-transform sampling.

Tests compare against SciPy's ppf using the existing check_icdf helper.

A few other distributions in the same family (BetaBinomial, HyperGeometric) could reuse the same helper if you all want! This was very fun to work on. I met Daniel Lee at the Sloan conf back in March and have been using PyMc ever since--though I still enjoy Stan.

Related Issue

Checklist

  • Checked that the pre-commit linting/style checks pass
  • Included tests that prove the feature works
  • Added necessary documentation (docstrings)

Type of change

  • New feature / enhancement

@read-the-docs-community

read-the-docs-community Bot commented Jun 22, 2026

Copy link
Copy Markdown

@jacobpstein
jacobpstein marked this pull request as ready for review June 22, 2026 23:26
@ricardoV94

Copy link
Copy Markdown
Member

Can you split the bernoulli into it's own PR, it's much more trivial to review and approve than the ones requiring search

@jacobpstein
jacobpstein force-pushed the add-icdf-discrete-distributions branch from 1f662e1 to 1fb6f6f Compare June 29, 2026 20:37
@jacobpstein jacobpstein changed the title Implement icdf for Bernoulli, Poisson, Binomial, and NegativeBinomial Implement icdf for Poisson, Binomial, and NegativeBinomial Jun 29, 2026
@jacobpstein

jacobpstein commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Done! split the Bernoulli icdf into #8343. This PR now covers only the search-based distributions (Binomial, Poisson, NegativeBinomial).

Comment thread pymc/distributions/dist_math.py Outdated
whose quantile function has no closed form (e.g. ``Poisson``, ``Binomial``,
``NegativeBinomial``).

The search uses fixed-length ``scan`` loops (rather than a data-dependent

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have to think about this, C/Numba are more than happy with data dependent loops.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeahhhh, the real constraint I was designing around is JAX (and static-shape lowering generally), which can't do a data-dependent number of steps.

I went with fixed-length scan so the same icdf graph works across all backends without special-casing, at the cost of always running max_iters iterations even after convergence. One option is to use a data-dependent while loop, accepting that JAX would be unsupported (or fall back) for these icdfs. Something to think about.

Comment thread pymc/distributions/dist_math.py Outdated
return expr


def discrete_icdf_via_search(logcdf, value, lower, upper=None, *, max_iters=64):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be misremembering, but one of the libraries I looked at also allowed some candidate bounds or something to inform the starting point of the search, in case the callers can offer something useful. Not saying we have to, but if there are obvious cases and it's not too much complexity would be good to discuss

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm always open to learning some new libraries! Right now upper is already the big lever-- Binomial passes upper=n and skips the expansion phase. For the unbounded cases the obvious hint is the mean (mu for Poisson, n(1-p)/p for NegativeBinomial), so I could add an optional start that seeds the geometric expansion there instead of doubling from 1.

Payoff is modest though since bisection is already logarithmic, so a hint just trims a few iterations off phase 1. Low-complexity to add if you think it's worth it, otherwise happy to leave as a follow-up, and if there's an existing package whose approach we can reference here, even better

@ricardoV94

Copy link
Copy Markdown
Member

@jacobpstein implementation looks really clean. I don't have concrete feedback yet, have to marinate a bit on it. Let's some vague questions/thoughts above

@jacobpstein

Copy link
Copy Markdown
Contributor Author

Went ahead and added the start hint we talked about. Poisson seeds the bracket expansion with mu, NegativeBinomial with n(1-p)/p, and Binomial doesn't need it since upper=n already skips the expansion phase. Nice side effect: if the quantile you're asking for is at or below the guess (which it usually is), phase 1 is a no-op and we go straight to bisection.

On the loop question, I dug into this a bit more and unfortunately there's no middle ground. Adding an until condition to scan turns it into a while-scan, and JAX flat-out refuses those ("While Scan cannot yet be converted to JAX"), even with a fixed n_steps cap. So it really is fixed-length-everywhere vs. faster-loops-but-no-JAX. My vote is to keep the fixed-length version for now (each step is just one logcdf eval, and the start hint trims the effective work) and revisit if pytensor ever learns to lower while-scans to JAX. But if you'd rather have the speed and drop JAX for these, it's a two-line change, so happy to go either way.

Fun discovery while stress-testing at large mu: pytensor's C gammaincc (GammaQ in scalar/c_code/gamma.c) quietly falls apart for a ≈ x ≳ 1e5. For example, gammaincc(1e6, 1e6) comes back as 0.653 when scipy says 0.4999. The NR series and continued fraction both need O(√x) iterations in that regime and just give up at the MAXITER=1024 cap without complaining. Standalone graphs happen to dodge it via the scipy path, but inside scan the fused graph goes through C, so Poisson logcdf/icdf can drift at extreme mu on the C backend. That's totally independent of this PR, but worth knowing. I'll open a pytensor issue for it; the new large-mu test here sits at mu=1e4, comfortably below the danger zone.

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.75%. Comparing base (b3033da) to head (93437bc).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #8339      +/-   ##
==========================================
+ Coverage   91.73%   91.75%   +0.01%     
==========================================
  Files         128      128              
  Lines       20697    20736      +39     
==========================================
+ Hits        18987    19026      +39     
  Misses       1710     1710              
Files with missing lines Coverage Δ
pymc/distributions/discrete.py 99.45% <100.00%> (+0.02%) ⬆️
pymc/distributions/dist_math.py 86.25% <100.00%> (+2.42%) ⬆️
pymc/distributions/truncated.py 99.48% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jacobpstein

Copy link
Copy Markdown
Contributor Author

Opened pymc-devs/pytensor#2291 for the gammaincc thing.

@ricardoV94

Copy link
Copy Markdown
Member

While Scan cannot yet be converted to JAX

We try not to compromise around JAX limitations. In this case we simply haven't implemented while loops for jax which cane be done (just not autodiffed through later)

@ricardoV94

Copy link
Copy Markdown
Member

Having said that I think this is already a great functionality to have and we can fine-tune in subsequent iterations / as demand calls.

Btw the implementation I vaguely recalled for these was from the boost library: https://github.com/boostorg/math/blob/8ee12a5355935cbaac5d5338372d0d0e3311b473/include/boost/math/distributions/binomial.hpp#L241-L270

- pip:
- numdifftools>=0.9.40
- mcbackend>=0.4.0
# pyarrow 25.0.0 (pulled in transitively by nutpie, installed later via pip

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rebase from main, we already merged similar pins

@ricardoV94

Copy link
Copy Markdown
Member

This is really nice work btw, in case I forgot to say so

@ricardoV94

Copy link
Copy Markdown
Member

Ah I think we need to handle batched parameters a bit more carefully.

This crashes because the initial value is broadcasted inside scan:

import pymc as pm
from pymc.logprob import icdf

# scalar quantile, vector parameter -> ValueError from pytensor.scan
icdf(pm.Poisson.dist(mu=[2.0, 5.0, 20.0]), 0.5).eval()

And a silent failure

import numpy as np
import scipy.stats as st
import pymc as pm
from pymc.logprob import icdf

q = np.array([[0.1], [0.5], [0.9]])          # shape (3, 1)
n = np.array([10, 40])                        # shape (2,)
p = np.array([0.3, 0.6])                      # shape (2,)  -> broadcasts to (3, 2)

got = icdf(pm.Binomial.dist(n=n, p=p), q).eval()
ref = st.binom.ppf(q, n, p)
print(got)
print(ref)
assert np.allclose(got, ref)                  # AssertionError

@ricardoV94 ricardoV94 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to handle the batched cases

The geometric bracket expansion can now be seeded with a caller-provided
guess (typically the distribution mean) instead of always doubling from
lower + 1. Poisson and NegativeBinomial pass their means; Binomial is
unchanged since it already provides upper=n.
icdf graphs are always float-typed because out-of-domain values map to
nan. When Truncated uses inverse-CDF sampling of a discrete base
distribution, the resulting RV silently became float64, so e.g. Hurdle
mixtures rejected it as a continuous component. Cast the icdf draw back
to the base RV dtype.
The scan carrying the (lo, hi) search state started at the shape of the
quantile value, but logcdf(k) broadcasts k against the distribution
parameters. When the parameters had a larger broadcast shape than the
value, the state changed shape inside scan, raising a ValueError (scalar
value, vector parameters) or silently misbroadcasting (value and
parameters broadcasting to a larger common shape).

Probe logcdf once to obtain the full broadcast shape and start the
search state there. Only the shape of the probe is used, so the probe
computation is pruned from the compiled graph.
@jacobpstein
jacobpstein force-pushed the add-icdf-discrete-distributions branch from b30a084 to 93437bc Compare July 15, 2026 17:51
@jacobpstein

jacobpstein commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, thanks @ricardoV94 ! The scan's search state started at the shape of the quantile, so it changed shape once logcdf broadcast it against the parameters. Fixed by broadcasting the state to the full broadcast shape of value and parameters up front (the shape comes from a probe of logcdf that gets pruned from the compiled graph). Both of your examples now match scipy and are added as a regression test. Also rebased on main to drop the env pins that were merged separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants