Skip to content

Commit 93437bc

Browse files
committed
Broadcast icdf search state against distribution parameters
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.
1 parent 5781780 commit 93437bc

2 files changed

Lines changed: 36 additions & 7 deletions

File tree

pymc/distributions/dist_math.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,9 @@ def discrete_icdf_via_search(logcdf, value, lower, upper=None, *, start=None, ma
9999
whose quantile function has no closed form (e.g. ``Poisson``, ``Binomial``,
100100
``NegativeBinomial``).
101101
102-
The search uses fixed-length ``scan`` loops (rather than a data-dependent
103-
``while``) so that the resulting graph remains convertible to the C, Numba
104-
and JAX backends. Each step is idempotent once an element has converged, so
105-
running the full ``max_iters`` iterations does not change the result.
102+
The search uses fixed-length ``scan`` loops rather than a data-dependent
103+
``until`` condition. Each step is idempotent once an element has converged,
104+
so running the full ``max_iters`` iterations does not change the result.
106105
107106
Parameters
108107
----------
@@ -131,12 +130,21 @@ def discrete_icdf_via_search(logcdf, value, lower, upper=None, *, start=None, ma
131130
of integers exactly representable as float64.
132131
"""
133132
log_value = pt.log(value)
134-
lower = pt.zeros_like(value) + pt.as_tensor_variable(lower, dtype="float64")
133+
lower = pt.as_tensor_variable(lower).astype("float64")
134+
135+
# The scan below carries (lo, hi) as recurrent state, whose shape must stay
136+
# fixed across iterations. Since logcdf(k) broadcasts k against the
137+
# distribution parameters, the state must start at the full broadcast shape
138+
# of value and the parameters. Probing logcdf gives that shape; only the
139+
# shape is used, so the probe computation itself is pruned from the graph.
140+
result_shape = logcdf(pt.broadcast_arrays(lower, log_value)[0]).shape
141+
log_value = pt.broadcast_to(log_value, result_shape)
142+
lower = pt.broadcast_to(lower, result_shape)
135143

136144
if upper is None:
137145
hi_init = lower + 1.0
138146
if start is not None:
139-
start = pt.zeros_like(value) + pt.as_tensor_variable(start, dtype="float64")
147+
start = pt.broadcast_to(pt.as_tensor_variable(start).astype("float64"), result_shape)
140148
hi_init = pt.maximum(pt.floor(start), hi_init)
141149

142150
# Phase 1: geometrically expand an upper bracket until cdf(hi) >= value.
@@ -153,7 +161,7 @@ def expand(lo, hi):
153161
lo, hi = lo[-1], hi[-1]
154162
else:
155163
lo = lower
156-
hi = pt.zeros_like(value) + pt.as_tensor_variable(upper, dtype="float64")
164+
hi = pt.broadcast_to(pt.as_tensor_variable(upper).astype("float64"), result_shape)
157165

158166
# Phase 2: bisect [lo, hi] keeping the invariant cdf(hi) >= value > cdf(lo - 1).
159167
def bisect(lo, hi):

tests/distributions/test_discrete.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,27 @@ def test_poisson(self):
399399
st.poisson.ppf(q, 1e4),
400400
)
401401

402+
def test_discrete_icdf_batched_params(self):
403+
# The search state must be broadcast against the distribution
404+
# parameters, not just the quantile, or scan fails / misbroadcasts
405+
mu = np.array([2.0, 5.0, 20.0])
406+
np.testing.assert_array_equal(
407+
icdf(pm.Poisson.dist(mu=mu), 0.5).eval(),
408+
st.poisson.ppf(0.5, mu),
409+
)
410+
411+
q = np.array([[0.1], [0.5], [0.9]])
412+
n = np.array([10, 40])
413+
p = np.array([0.3, 0.6]) # broadcasts with q to shape (3, 2)
414+
np.testing.assert_array_equal(
415+
icdf(pm.Binomial.dist(n=n, p=p), q).eval(),
416+
st.binom.ppf(q, n, p),
417+
)
418+
np.testing.assert_array_equal(
419+
icdf(pm.NegativeBinomial.dist(n=n, p=p), q).eval(),
420+
st.nbinom.ppf(q, n, p),
421+
)
422+
402423
@pytest.mark.parametrize("n", [2, 3, 4])
403424
def test_categorical(self, n):
404425
domain = Domain(range(n), dtype="int64", edges=(0, n))

0 commit comments

Comments
 (0)