A Bessel function that overflows before the result: the VonMises log-density in PyMC
In PyMC, a Python library for probabilistic programming, VonMises.logp returned -inf for a large concentration kappa: from about 92 in float32, from about 710 in float64. I fixed it in PR #8503, merged about two and a half hours after I opened it.
When it happens
import pymc as pm
import scipy.stats as st
print(pm.logp(pm.VonMises.dist(mu=0.0, kappa=100.0), 0.0).eval())
print(st.vonmises.logpdf(0.0, 100.0))
without the fix: -inf SciPy: 1.382390243648071
with the fix: 1.382390229428057
Under the default cast policy PyTensor makes a Python float that float32 holds exactly, such as 100.0, a float32 constant, so the eighth digit differs; 100.1 stays float64. A model with pm.VonMises("x", 0.0, 100.0) had a log-probability of -inf at its starting point.
How the density is computed
logp was kappa * cos(mu - value) - log(2 pi) - log(i0(kappa)). i0, a Bessel function, grows like exp(kappa). In float32 i0(91) is 1.4e38 and i0(92) is inf; in float64 i0(709) is 1.2e306 and i0(710) is inf. Then log(inf) is inf and logp is -inf.
Why the gap existed
The existing test_vonmises takes kappa from Rplus, whose finite values stop at 100 in float64, far below 710.
The fix
The continuous.py diff, without context lines:
@@ -3178 +3178 @@ class VonMises(CircularContinuous):
- res = kappa * pt.cos(mu - value) - pt.log(2 * np.pi) - pt.log(pt.i0(kappa))
+ res = kappa * (pt.cos(mu - value) - 1) - pt.log(2 * np.pi) - pt.log(i0e(kappa))
i0e(x) is exp(-x) * i0(x) for positive x, so log(i0(kappa)) equals kappa + log(i0e(kappa)). The kappa term joins the cosine term, and for a positive kappa, kappa * (cos(mu - value) - 1) is never above zero.
The test
test_vonmises_large_kappa compares logp at -0.1, 0.0 and 0.1 with scipy.stats.vonmises.logpdf, rtol=1e-6, for kappa of 100.0 and np.float64(1000.0). The first is a float32 constant, the second stays float64. Without the fix the 100.0 and 1000.0 cases both fail; with it both pass. Like test_vonmises, it is skipped when floatX is float32.
What the maintainer did
@ricardoV94 asked for benchmarks on stability changes, “to understand if it’s an absolute improvement or potential trade-off”, with the numba backend as reference. I replied with a table: numba PR/main 0.99 to 1.02, and about 1% slower on the C linker at 100,000 observations. They wrote “Thanks, looks solid”, approved, and merged it with the code, test and commit message unchanged.
The lesson
- The overflow point depends on the dtype as well as the formula. Find each one by evaluating the function, then give the test a value above it in each dtype.
100.0is above the float32 limit;np.float64(100.0)is below the float64 limit and passes without the fix. - Expect to be asked what a stability change costs. Time the function the sampler compiles,
logp_dlogp, on the maintainer’s reference backend, and show ratios between the two versions.
If your log-density takes the log of a fast-growing function, reply on X.