Numerical accuracy¶
The maths runs on statrs, so for the special-function families
(Normal, LogNormal, Beta, Binomial) accuracy is largely inherited: where statrs is good,
so is this library, and where statrs 0.19 has a known defect, this release inherits that too. The
inherited defects are tracked upstream and listed below with a regime and a magnitude, next to the
limits that are structural. This page states the posture, the tolerances, and every limit worth
knowing.
How it is checked¶
make audit (tools/accuracy/audit.py) sweeps every distribution, every public method and every
parameter regime against an mpmath oracle at 50 digits, including inputs
many decades past where scipy itself saturates. Each probe is classified rather than merely
measured, because a single relative-error number says nothing about the case that matters most: a
method that returns -inf or 0.0 where the true value is finite and representable is a different
kind of failure from one that is off in the last few digits.
The tolerance a method claims depends on what it is made of: 1e-12 for elementary closed forms,
1e-10 for special-function methods, 1e-9 for log-scale results, and 1e-6 or integer equality
for a discrete ppf resolved by binary search.
One honesty note for this release: an unfiltered sweep does not run to completion, because the
Beta.ppf / isf extreme-tail probes land in the statrs non-termination band documented below.
make audit therefore skips those two by default until the upstream fix lands, and records each skip
in the report with its reason rather than dropping it. --skip replaces that default set, so a bare
--skip runs the full sweep and will not return.
Use the log methods in the tails, where they are log methods¶
cdf and sf return 0.0 once the true value drops below ~1e-308, which is a float64 range
limit and not something an algorithm can fix. For Normal, LogNormal and the closed-form
distributions (Uniform, Exponential, Cauchy, Pareto, Weibull, Bernoulli, Geometric,
DiscreteUniform), log_cdf and log_sf stay finite far past that and are the right methods for tail
scoring. Likewise isf(q) rather than ppf(1 - q) on those ten: forming the complement quantises the tail mass to
1.1e-16 absolute before any inverse runs.
In this release that advice does not extend to Beta and Binomial. Their log_cdf / log_sf
are the linear value's logarithm, so they return -inf at exactly the point where cdf / sf
underflow, and their isf is the ppf(1 - quantile) composition, so it carries the complement's
quantisation (1.1e-16 / q relative, and for q below 1.1e-16 the complement is exactly 1.0,
so Beta.isf returns the upper support bound 1.0 and Binomial.isf returns n). The regimes and
magnitudes are in the inherited limits below.
Known limits¶
Structural¶
-
Discrete
ppf/isfat a step boundary.Binomial.ppfis a binary search resolved to the cdf's own precision, so a quantile within1e-12relative of a cdf step may return the neighbouring support point.Geometric.ppf/isfdecide the same tie in the log domain, testingk * log1p(-p)againstlog1p(-q)rather than re-derivingcdf(k). That is deliberate: across 1997 probes sitting on and either side of exact step boundaries it disagrees with exact rational arithmetic 357 times, against 490 for the alternative. The price is thatppfandcdfare not exact mutual inverses there, and the miss goes both ways, by at most one support point: atp = 1e-8withq = sf(1),isf(q)is2where1is the answer. On a step the two roundings decide the last bit, so which side a given quantile falls on is also the platform'sexpandlog1pand not the rule alone: atp = 0.1withqone ulp abovecdf(10),ppf(q)is10on Apple's libm and11on glibc, because theirexpputscdf(10)itself an ulp apart. Neither libm is correctly rounded, and a last-bit difference sits well inside the error glibc documents for it. Only the one-support-point bound is portable, so it is the only thing pinned.scipy'sgeom._ppfmade the opposite trade: it tests against its own_cdf, soppf(cdf(k))round-trips tokthere.Geometric(0.3).ppf(0.51)shows the two rules part. The double nearest0.51sits8.9e-18above the exactcdf(2) = 0.51, so the two readings of the input have different answers:2for the decimal0.51,3for the stored double. Both libraries form the same ratio,log1p(-0.51) / log1p(-0.3) = 2.0000000000000004, and ceil it to3; the step-back decides. scipy re-derivescdf(2)in the linear domain, where itsexpm1lands exactly on0.51, socdf(2) >= qreads true and it steps back to2. This library compares2 * log1p(-0.3)againstlog1p(-0.51), where the two sides differ by one ulp, and answers3.isf(0.49)mirrors it (3here,2in scipy, whoseisfisppf(1 - q)). Parity is gated at integer equality rather than at a float tolerance.DiscreteUniform.ppf/isfare closed forms rather than searches, andppfcarries scipy's own rounding, bit for bit: in a one-ulp quantile window above each cdf step edge,q * Nrounds down across the integer boundary and the answer sits one support point below the exact rational one, so therecdf(ppf(q)) < q. Outside that window both inverses agree with exact rational arithmetic, checked onN = 6, 8, 15, 101and1000. On an exactly representable edge the two contracts diverge, and this library inverts its ownsfrather than the exact rational:isfprobes both neighbours of its candidate and keeps the smallest point whose survival quotient still satisfies the quantile, soisf(sf(x)) == xholds for ⅚, 15/15 and 101/101 support points on(1,6),(-5,9)and(0,100), against 2/6, 7/15 and 58/101 under the rational rule. The one remaining miss issf's, not the inverse's: its reciprocal multiply lands an ulp below the exactk / N, so the point it came from genuinely no longer satisfies the survival contract at that quantile, and the portable bound stays one support point. * Afloatevaluation point cannot address a support narrower than one float step. At2**62one float step is 1024 wide, so all 11 points of{min, ..., min + 10}denote the samefloat64.cdf,sfand their logs then answer for the whole support at once, becausevalue >= maxis true for each of them;pmf/log_pmftest membership rather than a count, so they are unaffected. Passing the point as a Pythonintkeeps the arithmetic exact, since the count is subtracted inInt64inside the support:cdf(min + 5)onDiscreteUniform(2**62, 2**62 + 10)is6/11from anintand1.0from the equalfloat. It applies to any support whose width is below2**-52of its magnitude. *DiscreteUniform.ppf/isfreturn fewer distinct points than such a support has. The same regime on the output side, and there is nointspelling to escape to: the candidate is formed asmin + ceil(q * N) - 1inFloat64, wheremin + 1 - 1is notminabove2**53.DiscreteUniform(2**60, 2**60 + 4).ppf(q)answersminfor everyq, andDiscreteUniform(2**53 + 2, 2**53 + 12)resolves 3 of its 11 points. Both inverses are exact for bounds inside±2**53, which is where a support that afloat64quantile can address at all lives. * A moment can be correctly rounded and still land outside the support.meanandmedianform the midpoint asmin + (max - min) // 2inInt64and round once, so they are exact for bounds inside±2**53and within one ulp everywhere else. Summing the two bounds inFloat64instead would round each before they cancel, which costs up to1.2e-13relative for bounds straddling zero. For a support narrower than one float step near theInt64extremes the midpoint can still sit up to half a step outside[min, max]when compared in exact integers: it does so for about 99% of random supports of width< 20drawn from[2**62, 2**63), because the bounds themselves are not representable there. It never happens for a support wider than one float step, nor anywhere inside±2**53. * A constant parameter and a column parameter can differ in the last bit.Uniform(-2.5, 7.5).variance()andUniform(pl.col("lo"), pl.col("hi")).variance()evaluate the same closed form, but polars folds the constant spelling over one row and the column spelling overn, and the two kernels do not round identically. It reaches the methods evaluated as polars expressions rather than in Rust:Uniform's moments, andGeometric.std/.entropy. The difference is at or below1e-15relative, roughly 4x a double's ULP. Everything backed bystatrsruns the same Rust body either way and stays bit-identical.Geometric's two are narrower than the rest: both divide byp, and only apl.repeat(p, n=pl.len())parameter moves the last bit, because polars keeps that spelling scalar-backed and divides by it with a reciprocal multiply. A materialisedpcolumn matches the constant bit for bit. *float64range, not algorithm. Some quantities genuinely exceed the type:LogNormal's variance overflows abovesigma ~ 18.8, andExponential.pdflands in the subnormal range oncerate * xpasses ~708, where only one or two significant digits remain.log_pdfis exact well past that.Cauchyhas one such quantity, at the small end ofscale. Its peak density is1 / (pi scale), which exceedsfloat64belowscale ~ 1.77e-309, soCauchy(0, 1e-310).pdf(0.0)is+inf. That is the density itself leaving the type, not an intermediate: the same object answerspdf(1e-156)as31.8andpdf(1.0)as3.18e-311, andlog_pdfis exact throughout (708.04at the point wherepdfreports3.15e307).entropyhas no range limit anywhere inscale.ppfandisfoverflow to an infinity exactly where the quantile itself leaves the type:Cauchy(0, 1e308).ppf(0.1)is-infbecause the true-3.08e308is pastfloat64's largest value. The audited range isscalein1e-8to1e8.Paretohas the same kind of limit at both ends of its quantile functions.ppfandisfarescale * exp(t)withtthe exponential quantile, and they overflow to+infexactly where the answer does:Pareto(1e-300, 1.0).isf(1e-310)is1e10although a singleexp(713.8)is not representable, whilePareto(1.0, 1e-8).median()is+infbecause the true2 ** 1e8is.medianis the baseppf(0.5)rather than a second spelling of the same formula, so it carries that guard too. The other methods read the exponential's closed form atln(x / scale), formed from the exact excess(x - scale) / scale, socdfkeeps full relative precision atx = scale (1 + 1e-14)where the literal1 - (scale / x) ** shapeis off by1.5e-2; where the excess itself overflows, under a tinyscalebeside an ordinaryx, a difference of logs takes over. The audited range isshapein1e-8to1e8andscalein1e-8to1e8.Weibullreads the unit exponential at the power(x / scale) ** shape, formed asexp(shape ln(x / scale))with the log ratio taken from the exact excess(x - scale) / scalenearscale: the literal power rounds the ratio first, and the exponent magnifies that rounding intoshape * 1.1e-16relative.log_cdfis the log of the power itself belowt = 2^-53, so it stays finite wherecdfhas underflowed:Weibull(100, 1e-5).log_cdf(1e-9)is-921.03.varianceandstddo not formGamma(1 + 2 / shape) - Gamma(1 + 1 / shape) ** 2literally: the two gammas tend to1and the difference topi ** 2 / (6 shape ** 2), so the subtraction is1e-7relative off atshape = 1e4and3.7xat1e8, instatrsandscipyalike. Aboveshape = 8the log-gamma ratio is a series in1 / shapeinstead, and the variance holds1e-13relative toshape = 1e8; the gamma-function moments otherwise hold~1e-14, whatstatrs' Lanczosgammaandln_gammacarry near1and2.meanandstdarescale * exp(t)withta log-gamma, so they saturate where the moment does rather than where the gamma function does:Weibull(0.004, 1e-300).mean()is3.23e192althoughGamma(251)is not representable, andvarianceis the square ofstdrather than a second spelling of the same product. That range costs a few ulps of the log-gamma times|t|, so the two moments ease to2e-13wheretreaches the hundreds (shape = 0.01) rather than saturating there. The audited range isshapein1e-8to1e8andscalein1e-8to1e8; pastshape ~ 6.7e153the series argument1 / shape ** 2underflows andvarianceandstdcollapse to0, as they do inscipy. *UInt64range, for a discrete sample.Geometric.sampledraws a trial count, which averages1 / p, so a small enoughpputs the draw pastu64::MAX, where it saturates. A single draw does so with probabilityexp(-u64::MAX * p): negligible atp = 1e-18, 16% at1e-19and 83% at1e-20.mean()and the other moments areFloat64and stay exact far past that. No other sampler has a reachable limit:BernoulliisBooleanandBinomial's count is bounded byn.
Inherited from statrs 0.19, tracked upstream¶
The defects below live in the statrs 0.19 routines this release binds. Each is being reported
upstream: filed reports are linked, and the remaining links are added here as each one is filed. Per
the contract at the bottom of this page, a bullet comes out of this list when a fix lands.
Beta.ppf/isfin the extreme lower tail. Thestatrsinverse (inv_beta_reg, an implementation of AS 64) has an unguarded Newton step and an unbounded step-halving loop, which costs three regimes. Belowq ~ 1e-165it panics, and a panic inside the plugin aborts the whole query, not just the row. Forqin roughly[1e-150, 1e-60]at some shapes below 1 it does not return in reasonable time (over 15 s for a single row at(a, b) = (200, 2)). And its convergence floor is absolute rather than relative, so results saturate to one constant across decades:Beta(0.05, 0.05).ppf(q)returns the same value fromq = 1e-40all the way down to1e-160.q >= ~1e-40at ordinary shapes is well-behaved.isfcomposesppf(1 - q), so it shares all three regimes on top of the complement quantisation above. Filed upstream: statrs#435.BetaandBinomiallog_cdf/log_sfare linear compositions. They return-infas soon ascdf/sfunderflows:Beta(200, 2).log_cdf(0.001)is-infagainst a true-1376.25, andBinomial(100_000, 0.001).log_sf(5000)is-infagainst a true-14791.39.scipy'slogcdf/logsfare naive for this family too, so the two libraries agree while both are-inf. The fix is a log-space regularized incomplete beta, tracked upstream as statrs PR #421.BetaandBinomialat very large shapes.cdf/sfrunstatrs' regularized incomplete-beta continued fraction (beta_reg), which stops at 141 terms. That cap is first exceeded somewhere between shapes of1e4and1e5, after which the fraction truncates silently:Beta(s, s).cdf(0.5)is exactly0.5by symmetry, and statrs returns0.4912ats = 1e6,0.2129at1e7, and-1.147at1e8, a probability outside[0, 1]. The audited range covers shapes to1e3. Filed upstream: statrs#434.LogNormal.pdfunderflows in a left-tail band. It returns0.0across up to 49 decades where the density is finite and representable:LogNormal(0, 5).pdf(1.38e-87)is0.0against a true2.11e-262.log_pdfis exact there (-602.55at that point) and is the workaround.
What to do if you find a defect¶
Two acceptable outcomes and no third: the algorithm gets fixed, or the caveat gets documented here with a regime and a magnitude. A runtime warning is never the fix, because it cannot fire per-row from inside the engine. See Contributing > Numerical stability for the rules a new distribution has to satisfy.