gam_inference/probability.rs
1use gam_problem::types::LikelihoodSpec;
2use gam_solve::estimate::EstimationError;
3use gam_solve::mixture_link::inverse_link_jet_for_family_public;
4use ndarray::{Array1, ArrayView1};
5use statrs::function::beta::{beta_reg, inv_beta_reg};
6use statrs::function::erf::erfc;
7
8/// Standard normal PDF φ(x).
9#[inline]
10pub fn normal_pdf(x: f64) -> f64 {
11 const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
12 INV_SQRT_2PI * (-0.5 * x * x).exp()
13}
14
15/// Standard normal CDF Φ(x) evaluated via the exact special-function identity
16///
17/// Phi(x) = 0.5 * erfc(-x / sqrt(2)).
18///
19/// This is the exact Gaussian CDF semantics used throughout the codebase. The
20/// numerical `erfc` implementation may use internal approximations, but the
21/// returned function is the standard normal CDF itself rather than a separate
22/// polynomial surrogate surface.
23#[inline]
24pub fn normal_cdf(x: f64) -> f64 {
25 0.5 * erfc(-x / std::f64::consts::SQRT_2)
26}
27
28/// Scaled complementary error function `erfcx(x) = exp(x²) · erfc(x)`,
29/// specialized to `x ≥ 0`. The implementation now lives in the lowest crate
30/// (`gam-math`) so the survival/probit cluster can consume it without reaching
31/// up into `inference`; re-exported here to keep
32/// `crate::probability::erfcx_nonnegative` resolving for all existing callers.
33pub use gam_math::probability::erfcx_nonnegative;
34
35/// Computes `log(1 - exp(-a))` for `a >= 0` without cancellation. Implementation
36/// lives in `gam-math`; re-exported to keep `crate::probability::log1mexp_positive`
37/// resolving for all existing callers.
38pub use gam_math::probability::log1mexp_positive;
39
40/// Numerically stable signed log-sum-exp. Implementation lives in `gam-math`;
41/// re-exported to keep `crate::probability::signed_log_sum_exp` resolving for all
42/// existing callers.
43pub use gam_math::probability::signed_log_sum_exp;
44
45/// Numerically stable `C(n,k) = n! / (k!·(n−k)!)` as `f64`. The
46/// implementation now lives in the lowest crate (`gam-math`) so the
47/// terms/basis cluster can consume it without reaching up into `inference`;
48/// re-exported here to keep `crate::probability::binomial_coefficient_f64`
49/// resolving for all existing callers.
50pub use gam_math::special::binomial_coefficient_f64;
51
52/// Evaluate `(Σ_k coeffs[k]·x^k) · exp(−x)` without overflow. The
53/// implementation lives in `gam-math` so terms/basis code can consume it without
54/// reaching up into `inference`.
55pub use gam_math::special::stable_polynomial_times_exp_neg;
56
57/// Numerically stable `ln Φ(x)` for the standard normal CDF. Implementation lives
58/// in `gam-math`; re-exported to keep `crate::probability::normal_logcdf` resolving
59/// for all existing callers.
60pub use gam_math::probability::normal_logcdf;
61
62/// Numerically stable `ln(1 − Φ(x)) = ln Φ(−x)` for the standard normal survival
63/// function. Implementation lives in `gam-math`; re-exported to keep
64/// `crate::probability::normal_logsf` resolving for all existing callers.
65pub use gam_math::probability::normal_logsf;
66
67/// Joint evaluation of `ln Φ(x)` and the Mills-ratio analogue `φ(x) / Φ(x)`.
68/// Implementation lives in `gam-math`; re-exported to keep
69/// `crate::probability::signed_probit_logcdf_and_mills_ratio` resolving for all
70/// existing callers.
71pub use gam_math::probability::signed_probit_logcdf_and_mills_ratio;
72
73/// Standard normal quantile Φ⁻¹(p) using Acklam's rational approximation.
74/// Implementation lives in `gam-math`; re-exported to keep
75/// `crate::probability::standard_normal_quantile` resolving for all existing callers.
76pub use gam_math::probability::standard_normal_quantile;
77
78/// Quantile (inverse CDF) of a Gamma distribution parameterized by shape
79/// `k > 0` and scale `θ > 0` at probability `p ∈ (0, 1)`: the value `x` with
80/// `P(X ≤ x) = p` for `X ~ Gamma(shape = k, scale = θ)` (mean `kθ`, variance
81/// `kθ²`).
82///
83/// Equals `θ · Q(p; k)`, where `Q(p; k)` inverts the regularized lower
84/// incomplete gamma `P(k, x)` (the unit-scale Gamma CDF). `p ≤ 0` maps to the
85/// `0` support floor and `p ≥ 1` to `+∞`; a non-finite or non-positive shape or
86/// scale yields `NaN`.
87///
88/// This is the building block for *skew-aware* response-scale predictive
89/// (observation) intervals: a Gamma response is strongly right-skewed, so the
90/// symmetric `μ ± z·σ` band mis-covers each tail even when its width (variance)
91/// is correct. Equal-tailed Gamma quantiles place the right mass in each tail.
92pub fn gamma_quantile(p: f64, shape: f64, scale: f64) -> f64 {
93 if !(shape.is_finite() && shape > 0.0 && scale.is_finite() && scale > 0.0) {
94 return f64::NAN;
95 }
96 scale * inverse_regularized_lower_gamma(p, shape)
97}
98
99/// Equal-tailed predictive interval for a strictly-positive, right-skewed
100/// response modelled as a Gamma whose first two moments match a point
101/// prediction: mean `mu` and total predictive variance `total_var`
102/// (estimation + observation noise). Returns the pair of Gamma quantiles at
103/// lower-tail probabilities `p_lo < p_hi` — the skew-correct replacement for a
104/// symmetric `mu ± z·σ` band, which for a Gamma pins the lower edge near the
105/// support floor and mis-covers each tail (#817).
106///
107/// Moment matching fixes `shape k = mu²/V` and `scale θ = V/mu`, so the
108/// predictive carries exactly the requested mean and variance. When estimation
109/// uncertainty vanishes (`total_var → φμ²`) this is *exact*: `k → 1/φ`,
110/// `θ → φμ`, recovering the conditional Gamma `Gamma(shape = 1/φ, scale = φμ)`.
111/// With nonzero estimation variance it is the moment-matched Gamma predictive —
112/// the minimal skew-correct widening.
113///
114/// Returns `None` when the inputs are degenerate (non-positive mean or
115/// variance, non-finite), or when the incomplete-gamma inverse yields a
116/// non-finite / mis-ordered pair — which happens for an enormous shape, where
117/// the Gamma is essentially Gaussian and the caller should fall back to the
118/// then-accurate symmetric edges.
119pub fn gamma_moment_matched_interval(
120 mu: f64,
121 total_var: f64,
122 p_lo: f64,
123 p_hi: f64,
124) -> Option<(f64, f64)> {
125 if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
126 return None;
127 }
128 let shape = mu * mu / total_var;
129 let scale = total_var / mu;
130 let q_lo = gamma_quantile(p_lo, shape, scale);
131 let q_hi = gamma_quantile(p_hi, shape, scale);
132 if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
133 Some((q_lo, q_hi))
134 } else {
135 None
136 }
137}
138
139/// Quantile (inverse CDF) of a Beta distribution with shape parameters `a > 0`
140/// and `b > 0` at probability `p ∈ (0, 1)`: the value `x ∈ [0, 1]` with
141/// `I_x(a, b) = p`, where `I` is the regularized incomplete beta (the Beta CDF).
142///
143/// `p ≤ 0` maps to the `0` support floor and `p ≥ 1` to the `1` support ceiling;
144/// a non-finite or non-positive shape yields `NaN`. Built on the AS 64/109
145/// inverse-incomplete-beta routine.
146///
147/// This is the bounded-support analogue of [`gamma_quantile`]: a Beta response
148/// (a proportion modelled by the Beta family) is skewed toward whichever edge
149/// its mean is near, so a symmetric `μ ± z·σ` predictive band mis-covers *both*
150/// tails even when its width is correct. Equal-tailed Beta quantiles place the
151/// right mass in each tail (#1194).
152pub fn beta_quantile(p: f64, a: f64, b: f64) -> f64 {
153 if !(a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0) {
154 return f64::NAN;
155 }
156 if !p.is_finite() || p <= 0.0 {
157 return 0.0;
158 }
159 if p >= 1.0 {
160 return 1.0;
161 }
162 inv_beta_reg(a, b, p)
163}
164
165/// Equal-tailed predictive interval for a `(0, 1)`-bounded response modelled as a
166/// Beta whose first two moments match a point prediction: mean `mu ∈ (0, 1)` and
167/// total predictive variance `total_var` (estimation + observation noise).
168/// Returns the pair of Beta quantiles at lower-tail probabilities `p_lo < p_hi` —
169/// the skew-correct replacement for a symmetric `mu ± z·σ` band, which for a
170/// skewed Beta lands *both* edges below the corresponding true quantile and so
171/// mis-covers each tail (#1194).
172///
173/// Moment matching fixes the precision `φ = a + b = μ(1−μ)/V − 1`, then
174/// `a = μφ`, `b = (1−μ)φ`, so the predictive carries exactly the requested mean
175/// and variance. When estimation uncertainty vanishes
176/// (`total_var → μ(1−μ)/(1+φ₀)`) this is *exact*: `φ → φ₀`, recovering the
177/// conditional `Beta(μφ₀, (1−μ)φ₀)`. With nonzero estimation variance it is the
178/// moment-matched Beta predictive — the minimal skew-correct widening.
179///
180/// Returns `None` when the inputs are degenerate (mean outside `(0, 1)`,
181/// non-positive variance, non-finite), or when the requested variance reaches
182/// the Bernoulli ceiling `μ(1−μ)` (no Beta has that much spread for the given
183/// mean) — in which case the caller falls back to the symmetric edges.
184pub fn beta_moment_matched_interval(
185 mu: f64,
186 total_var: f64,
187 p_lo: f64,
188 p_hi: f64,
189) -> Option<(f64, f64)> {
190 if !(mu.is_finite() && mu > 0.0 && mu < 1.0 && total_var.is_finite() && total_var > 0.0) {
191 return None;
192 }
193 // A Beta on (0,1) with mean μ can carry variance only up to the Bernoulli
194 // limit μ(1−μ); at or beyond it no Beta exists, so the moment match fails.
195 let max_var = mu * (1.0 - mu);
196 if total_var >= max_var {
197 return None;
198 }
199 let precision = max_var / total_var - 1.0; // = a + b > 0
200 let a = mu * precision;
201 let b = (1.0 - mu) * precision;
202 let q_lo = beta_quantile(p_lo, a, b);
203 let q_hi = beta_quantile(p_hi, a, b);
204 if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
205 Some((q_lo, q_hi))
206 } else {
207 None
208 }
209}
210
211/// CDF of a Negative-Binomial with mean `μ ≥ 0` and dispersion `θ > 0`
212/// (`Var = μ + μ²/θ`) at the integer count `k ≥ 0`:
213/// `P(Y ≤ k) = I_{θ/(θ+μ)}(θ, k+1)`, the regularized incomplete beta. Increasing
214/// in `k`; `P(Y ≤ 0) = (θ/(θ+μ))^θ` is the zero mass.
215#[inline]
216fn negative_binomial_cdf_at(k: f64, theta: f64, prob: f64) -> f64 {
217 // `prob ∈ (0, 1)`; `beta_reg` requires its last argument in [0, 1].
218 beta_reg(theta, k + 1.0, prob.clamp(0.0, 1.0))
219}
220
221/// Smallest integer `k` with `cdf(k) ≥ p`, found by a geometric bracket grown
222/// from `seed` followed by an integer bisection (invariant `cdf(lo) < p ≤
223/// cdf(hi)`). `cdf` must be a monotone non-decreasing lower-tail CDF on the
224/// non-negative integers. Returns `+∞` if the upper bracket grows past the
225/// `1e18` finite-arithmetic backstop without reaching `p`.
226///
227/// Shared root finder for the discrete count quantiles (Negative-Binomial,
228/// Poisson): both seed a normal approximation on their own moments and then run
229/// this identical bracket-and-bisect. Callers must already have handled the
230/// `cdf(0) ≥ p` zero-atom short-circuit.
231fn count_quantile_bracket_bisect(cdf: impl Fn(f64) -> f64, seed: f64, p: f64) -> f64 {
232 let mut lo: f64;
233 let mut hi: f64;
234 if cdf(seed) >= p {
235 hi = seed;
236 lo = 0.0;
237 // Tighten `lo` upward toward `hi` so the bisection starts narrow.
238 let mut step = 1.0;
239 let mut cand = seed - 1.0;
240 while cand > 0.0 && cdf(cand) >= p {
241 hi = cand;
242 step *= 2.0;
243 cand = seed - step;
244 }
245 if cand > 0.0 {
246 lo = cand; // CDF(cand) < p
247 }
248 } else {
249 lo = seed; // CDF(seed) < p
250 let mut step = 1.0;
251 let mut cand = seed + 1.0;
252 // CDF → 1 as k → ∞ and p < 1, so this terminates; the cap is a
253 // finite-arithmetic backstop (returns an effectively infinite edge).
254 while cdf(cand) < p {
255 lo = cand;
256 step *= 2.0;
257 cand = seed + step;
258 if cand > 1.0e18 {
259 return f64::INFINITY;
260 }
261 }
262 hi = cand;
263 }
264
265 // Bisection for the smallest integer k with CDF(k) ≥ p, maintaining the
266 // invariant CDF(lo) < p ≤ CDF(hi).
267 while hi - lo > 1.0 {
268 let mid = (lo + (hi - lo) / 2.0).floor();
269 if cdf(mid) >= p {
270 hi = mid;
271 } else {
272 lo = mid;
273 }
274 }
275 hi
276}
277
278/// Quantile (inverse CDF) of a Negative-Binomial with mean `μ ≥ 0` and
279/// dispersion `θ > 0` at probability `p ∈ (0, 1)`: the smallest integer count
280/// `k ≥ 0` with `P(Y ≤ k) ≥ p`, returned as an `f64`.
281///
282/// `p ≤ 0` maps to the `0` support floor and `p ≥ 1` to `+∞`; a non-finite or
283/// non-positive dispersion, or a non-finite / negative mean, yields `NaN`; a
284/// zero mean is the degenerate point mass at `0`.
285///
286/// Unlike the continuous Gamma/Beta quantiles, the NB is *discrete* with a real
287/// atom at zero, so its skew-correct predictive band must come from the genuine
288/// integer quantiles — a moment-matched *continuous* surrogate (e.g. a Gamma)
289/// has no zero atom and grossly over-covers the lower tail on low-mean counts
290/// (#1193). A normal-approximation seed brackets the root, then an exact
291/// bisection on the incomplete-beta CDF finds the smallest qualifying integer.
292pub fn negative_binomial_quantile(p: f64, mu: f64, theta: f64) -> f64 {
293 if !(mu.is_finite() && mu >= 0.0 && theta.is_finite() && theta > 0.0) {
294 return f64::NAN;
295 }
296 if !p.is_finite() || p <= 0.0 {
297 return 0.0;
298 }
299 if p >= 1.0 {
300 return f64::INFINITY;
301 }
302 if mu == 0.0 {
303 return 0.0;
304 }
305 let prob = theta / (theta + mu); // P(success) ∈ (0, 1); mean = θ(1−prob)/prob = μ
306 let cdf = |k: f64| negative_binomial_cdf_at(k, theta, prob);
307
308 // The zero atom already covers the requested lower-tail mass on low-mean
309 // counts (the common right-skewed case), so short-circuit before bracketing.
310 if cdf(0.0) >= p {
311 return 0.0;
312 }
313
314 // Normal-approximation seed on the NB moments, floored into the support.
315 let var = mu + mu * mu / theta;
316 let z = standard_normal_quantile(p).unwrap_or(0.0);
317 let seed = (mu + z * var.sqrt()).floor().max(1.0);
318
319 // Bracket the smallest integer with CDF ≥ p: `lo` always satisfies
320 // CDF(lo) < p (starts at 0, which failed the short-circuit) and `hi`
321 // satisfies CDF(hi) ≥ p. Grow geometrically from the seed in whichever
322 // direction is needed.
323 count_quantile_bracket_bisect(&cdf, seed, p)
324}
325
326/// Equal-tailed predictive interval for a Negative-Binomial count response whose
327/// conditional law has mean `mu > 0` and dispersion `theta > 0`, widened for
328/// estimation uncertainty to a total predictive variance `total_var`
329/// (estimation + observation noise). Returns the pair of integer NB quantiles at
330/// lower-tail probabilities `p_lo < p_hi` — the skew-correct, zero-atom-aware
331/// replacement for a symmetric `mu ± z·σ` band, which on right-skewed counts
332/// sits below the true upper quantile and under-covers the upper tail (#1193).
333///
334/// Estimation uncertainty is folded in through an *effective dispersion*: an NB
335/// with mean `μ` has variance `μ + μ²/θ`, so the `θ_eff` matching the inflated
336/// total variance solves `μ + μ²/θ_eff = total_var`, i.e.
337/// `θ_eff = μ² / (total_var − μ)`. When estimation uncertainty vanishes
338/// (`total_var → μ + μ²/θ`) this is *exact*: `θ_eff → θ`, recovering the
339/// conditional `NB(μ, θ)`. With nonzero estimation variance `θ_eff < θ` widens
340/// the band — the minimal skew-correct widening that stays inside the NB family.
341///
342/// Returns `None` for degenerate inputs (non-positive mean / variance,
343/// non-finite), or a numerically mis-ordered pair, in which case the caller
344/// falls back to the symmetric edges.
345pub fn negative_binomial_moment_matched_interval(
346 mu: f64,
347 theta: f64,
348 total_var: f64,
349 p_lo: f64,
350 p_hi: f64,
351) -> Option<(f64, f64)> {
352 if !(mu.is_finite()
353 && mu > 0.0
354 && theta.is_finite()
355 && theta > 0.0
356 && total_var.is_finite()
357 && total_var > 0.0)
358 {
359 return None;
360 }
361 // `total_var = SE(μ̂)² + (μ + μ²/θ) > μ` always, so the excess is positive;
362 // fall back to the nominal dispersion only if a degenerate caller breaks it.
363 let excess = total_var - mu;
364 let theta_eff = if excess > 0.0 {
365 mu * mu / excess
366 } else {
367 theta
368 };
369 let q_lo = negative_binomial_quantile(p_lo, mu, theta_eff);
370 let q_hi = negative_binomial_quantile(p_hi, mu, theta_eff);
371 if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
372 Some((q_lo, q_hi))
373 } else {
374 None
375 }
376}
377
378/// CDF of a Poisson with mean `mu ≥ 0` at the integer count `k ≥ 0`:
379/// `P(Y ≤ k) = Q(k+1, μ)`, the regularized *upper* incomplete gamma (the standard
380/// Poisson↔gamma identity). Increasing in `k`; `P(Y ≤ 0) = e^{−μ}` is the zero mass.
381#[inline]
382fn poisson_cdf_at(k: f64, mu: f64) -> f64 {
383 // P(Y ≤ k) = Q(k+1, μ) = 1 − P(k+1, μ); `regularized_lower_gamma` is `P`.
384 (1.0 - regularized_lower_gamma(k + 1.0, mu)).clamp(0.0, 1.0)
385}
386
387/// Quantile (inverse CDF) of a Poisson with mean `mu ≥ 0` at probability
388/// `p ∈ (0, 1)`: the smallest integer count `k ≥ 0` with `P(Y ≤ k) ≥ p`,
389/// returned as an `f64`.
390///
391/// `p ≤ 0` maps to the `0` support floor and `p ≥ 1` to `+∞`; a non-finite or
392/// negative mean yields `NaN`; a zero mean is the degenerate point mass at `0`.
393///
394/// Like the Negative-Binomial, the Poisson is *discrete* with a real atom at
395/// zero, so its skew-correct predictive band must come from the genuine integer
396/// quantiles — a symmetric `μ ± z·σ` band sits below the true upper quantile on
397/// low-rate counts and under-covers the upper tail (the #817 defect, Poisson
398/// sibling of #1193). A normal-approximation seed brackets the root, then an
399/// exact bisection on the gamma-tail CDF finds the smallest qualifying integer.
400pub fn poisson_quantile(p: f64, mu: f64) -> f64 {
401 if !(mu.is_finite() && mu >= 0.0) {
402 return f64::NAN;
403 }
404 if !p.is_finite() || p <= 0.0 {
405 return 0.0;
406 }
407 if p >= 1.0 {
408 return f64::INFINITY;
409 }
410 if mu == 0.0 {
411 return 0.0;
412 }
413 let cdf = |k: f64| poisson_cdf_at(k, mu);
414
415 // The zero atom already covers the requested lower-tail mass on low-rate
416 // counts (the common right-skewed case), so short-circuit before bracketing.
417 if cdf(0.0) >= p {
418 return 0.0;
419 }
420
421 // Normal-approximation seed on the Poisson moments (Var = μ), floored into
422 // the support.
423 let z = standard_normal_quantile(p).unwrap_or(0.0);
424 let seed = (mu + z * mu.sqrt()).floor().max(1.0);
425
426 // Bracket the smallest integer with CDF ≥ p: `lo` always satisfies
427 // CDF(lo) < p (starts at 0, which failed the short-circuit) and `hi`
428 // satisfies CDF(hi) ≥ p. Grow geometrically from the seed in whichever
429 // direction is needed.
430 count_quantile_bracket_bisect(&cdf, seed, p)
431}
432
433/// Equal-tailed predictive interval for a Poisson count response whose
434/// conditional law has mean `mu > 0` (so `Var(Y|μ) = μ`), widened for estimation
435/// uncertainty to a total predictive variance `total_var ≥ μ` (estimation +
436/// observation noise). Returns the pair of integer quantiles at lower-tail
437/// probabilities `p_lo < p_hi` — the skew-correct, zero-atom-aware replacement
438/// for a symmetric `mu ± z·σ` band, which on low-rate counts sits below the true
439/// upper quantile and under-covers the upper tail (the #817 defect, Poisson
440/// sibling of #1193).
441///
442/// A pure Poisson has no free dispersion parameter to absorb estimation
443/// uncertainty, so the widening is carried by the *conjugate over-dispersed count
444/// law*: if the point estimate `μ̂` carries (approximately) a Gamma sampling
445/// uncertainty with mean `μ` and variance `SE(μ̂)² = total_var − μ`, the posterior
446/// predictive for a *new* Poisson draw is exactly a Negative-Binomial — the
447/// Gamma–Poisson mixture — with mean `μ` and dispersion `θ_eff = μ² / (total_var − μ)`
448/// (matching the inflated variance `μ + μ²/θ_eff = total_var`). As estimation
449/// uncertainty vanishes (`total_var → μ`, `θ_eff → ∞`) the NB collapses to the
450/// *exact* conditional Poisson, which is then used directly — both because it is
451/// the correct limit and because an NB with `θ → ∞` is numerically degenerate.
452/// The two regimes agree (both are integer quantiles that coincide once `θ_eff`
453/// is large), so the switch introduces no discontinuity in the emitted edge.
454///
455/// Returns `None` for degenerate inputs (non-positive mean, non-finite, or a
456/// total variance below the Poisson floor `μ`), or a numerically mis-ordered
457/// pair, in which case the caller falls back to the symmetric edges.
458pub fn poisson_moment_matched_interval(
459 mu: f64,
460 total_var: f64,
461 p_lo: f64,
462 p_hi: f64,
463) -> Option<(f64, f64)> {
464 if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
465 return None;
466 }
467 // Estimation uncertainty inflates the count variance beyond the Poisson
468 // floor `Var(Y|μ) = μ`; the excess is the (approximate) sampling variance of
469 // `μ̂`. A `total_var` below `μ` is degenerate (a caller broke the contract).
470 let excess = total_var - mu;
471 if excess < 0.0 {
472 return None;
473 }
474 // Above this effective dispersion the NB surrogate and the conditional
475 // Poisson agree to far more than the integer resolution of the quantile, and
476 // `negative_binomial_quantile`'s `I_{θ/(θ+μ)}(θ, k+1)` is better conditioned
477 // as the exact Poisson; below it the NB widening is genuine.
478 const THETA_EFF_MAX: f64 = 1.0e9;
479 let theta_eff = if excess > 0.0 {
480 mu * mu / excess
481 } else {
482 f64::INFINITY
483 };
484 let (q_lo, q_hi) = if theta_eff > THETA_EFF_MAX {
485 (poisson_quantile(p_lo, mu), poisson_quantile(p_hi, mu))
486 } else {
487 (
488 negative_binomial_quantile(p_lo, mu, theta_eff),
489 negative_binomial_quantile(p_hi, mu, theta_eff),
490 )
491 };
492 if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
493 Some((q_lo, q_hi))
494 } else {
495 None
496 }
497}
498
499/// CDF of a Tweedie compound Poisson–Gamma response (power `1 < p < 2`) with
500/// mean `mu > 0` and dispersion `phi > 0` at `y ≥ 0`:
501/// `P(Y ≤ y) = e^{−λ} + Σ_{k≥1} Poisson(k; λ)·GammaCDF(y; kα, γ)`, the mixture of
502/// a point mass at zero (no jumps) and `k` i.i.d. Gamma jumps. The Tweedie
503/// parameters map to `λ = μ^{2−p} / (φ(2−p))` (Poisson mean number of jumps),
504/// Gamma jump shape `α = (2−p)/(p−1)` and scale `γ = φ(p−1)μ^{p−1}`, which
505/// reproduce `E[Y] = μ` and `Var(Y) = φμ^p`.
506///
507/// The zero atom `e^{−λ}` is returned directly at `y = 0`. For `y > 0` the
508/// Poisson weights are accumulated in log-space and the series is truncated once
509/// the remaining Poisson mass beyond the current term is negligible — the Gamma
510/// CDF factor is ≤ 1, so the unsummed tail is bounded by the Poisson survival.
511#[inline]
512fn tweedie_cdf_at(y: f64, mu: f64, phi: f64, power: f64) -> f64 {
513 if !(y.is_finite() && y >= 0.0) {
514 return f64::NAN;
515 }
516 let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
517 let alpha = (2.0 - power) / (power - 1.0);
518 let scale = phi * (power - 1.0) * mu.powf(power - 1.0);
519 let zero_mass = (-lambda).exp();
520 if y <= 0.0 {
521 return zero_mass;
522 }
523 let x = y / scale; // unit-scale Gamma argument
524 // Poisson(k; λ) weights via a log-space recurrence: w_k = w_{k-1}·λ/k.
525 // Sum k ≥ 1 only; the k = 0 term contributes the zero atom (GammaCDF = 1 at
526 // any y > 0 for shape 0 is the degenerate point mass already in `zero_mass`).
527 let mut acc = zero_mass; // P(Y ≤ y) includes the no-jump mass (Y = 0 ≤ y)
528 let mut ln_w = -lambda; // ln Poisson(0; λ)
529 // Centre the truncation window on the Poisson mode so very large λ stays cheap.
530 let k_max = (lambda + 10.0 * lambda.sqrt()).ceil() as usize + 50;
531 let mut remaining = 1.0 - zero_mass; // Poisson mass still unaccounted for (k ≥ 1)
532 for k in 1..=k_max {
533 ln_w += lambda.ln() - (k as f64).ln();
534 let w = ln_w.exp();
535 remaining -= w;
536 // GammaCDF(y; kα, γ) = P(kα, y/γ) on the unit scale.
537 acc += w * regularized_lower_gamma(alpha * k as f64, x);
538 if remaining <= 1e-15 && k as f64 > lambda {
539 break;
540 }
541 }
542 acc.clamp(0.0, 1.0)
543}
544
545/// Quantile (inverse CDF) of a Tweedie compound Poisson–Gamma response
546/// (power `1 < p < 2`) with mean `mu > 0` and dispersion `phi > 0` at
547/// probability `q ∈ (0, 1)`: the value `y ≥ 0` with `P(Y ≤ y) = q`.
548///
549/// `q ≤ 0` maps to the `0` support floor and `q ≥ 1` to `+∞`. If the requested
550/// lower-tail probability is at or below the zero atom `e^{−λ}` the quantile is
551/// exactly `0` (the common right-skewed lower-tail case). Otherwise a normal seed
552/// on the Tweedie moments brackets the root, which is then refined by bisection
553/// on [`tweedie_cdf_at`] — the continuous part above the atom is strictly
554/// increasing, so the bracket converges.
555pub fn tweedie_quantile(q: f64, mu: f64, phi: f64, power: f64) -> f64 {
556 if !(mu.is_finite()
557 && mu > 0.0
558 && phi.is_finite()
559 && phi > 0.0
560 && power.is_finite()
561 && power > 1.0
562 && power < 2.0)
563 {
564 return f64::NAN;
565 }
566 if !q.is_finite() || q <= 0.0 {
567 return 0.0;
568 }
569 if q >= 1.0 {
570 return f64::INFINITY;
571 }
572 let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
573 let zero_mass = (-lambda).exp();
574 // The zero atom carries the lower-tail mass: q at or below it ⇒ quantile 0.
575 if q <= zero_mass {
576 return 0.0;
577 }
578
579 // Normal-approximation seed on the Tweedie moments, then geometric bracketing.
580 let var = phi * mu.powf(power);
581 let z = standard_normal_quantile(q).unwrap_or(0.0);
582 let mut hi = (mu + z * var.sqrt()).max(scale_floor(mu));
583 let cdf = |y: f64| tweedie_cdf_at(y, mu, phi, power);
584
585 // Grow `hi` until it covers `q`; `lo` stays below it. CDF → 1 as y → ∞.
586 let mut lo = 0.0_f64;
587 let mut guard = 0;
588 while cdf(hi) < q {
589 lo = hi;
590 hi *= 2.0;
591 guard += 1;
592 if guard > 200 || hi > 1.0e18 {
593 return f64::INFINITY;
594 }
595 }
596
597 // Bisection on the strictly-increasing continuous part above the atom.
598 for _ in 0..200 {
599 let mid = 0.5 * (lo + hi);
600 if cdf(mid) < q {
601 lo = mid;
602 } else {
603 hi = mid;
604 }
605 if hi - lo <= (hi.abs() + 1.0) * 1e-12 {
606 break;
607 }
608 }
609 0.5 * (lo + hi)
610}
611
612/// A strictly-positive starting scale for the Tweedie bracket: a small fraction
613/// of the mean keeps the initial `hi` inside the support when the normal seed
614/// underflows to or below zero on a heavily right-skewed row.
615#[inline]
616fn scale_floor(mu: f64) -> f64 {
617 (mu * 1e-3).max(f64::MIN_POSITIVE)
618}
619
620/// Equal-tailed predictive interval for a Tweedie compound Poisson–Gamma
621/// response (power `1 < p < 2`) whose conditional law has mean `mu > 0` and
622/// dispersion `phi > 0`, widened for estimation uncertainty to a total
623/// predictive variance `total_var` (estimation + observation noise). Returns the
624/// pair of Tweedie quantiles at lower-tail probabilities `p_lo < p_hi` — the
625/// skew-correct, zero-atom-aware replacement for a symmetric `mu ± z·σ` band,
626/// which on a right-skewed Tweedie sits below the true upper quantile and
627/// under-covers the upper tail (the #817 defect, Tweedie sibling of #1193).
628///
629/// Estimation uncertainty is folded in through an *effective dispersion*: a
630/// Tweedie with mean `μ` has variance `φμ^p`, so the `φ_eff` matching the
631/// inflated total variance solves `φ_eff·μ^p = total_var`, i.e.
632/// `φ_eff = total_var / μ^p`. When estimation uncertainty vanishes
633/// (`total_var → φμ^p`) this is *exact*: `φ_eff → φ`, recovering the conditional
634/// Tweedie. With nonzero estimation variance `φ_eff > φ` widens the band inside
635/// the Tweedie family — the minimal skew-correct widening. Unlike a moment-
636/// matched Gamma surrogate, this keeps the genuine zero atom, so it does not
637/// over-cover the lower tail on low-mean rows (#1193).
638///
639/// Returns `None` for degenerate inputs (non-positive mean / variance,
640/// non-finite, power outside `(1, 2)`) or a mis-ordered pair, in which case the
641/// caller falls back to the symmetric edges.
642pub fn tweedie_moment_matched_interval(
643 mu: f64,
644 phi: f64,
645 power: f64,
646 total_var: f64,
647 p_lo: f64,
648 p_hi: f64,
649) -> Option<(f64, f64)> {
650 if !(mu.is_finite()
651 && mu > 0.0
652 && phi.is_finite()
653 && phi > 0.0
654 && power.is_finite()
655 && power > 1.0
656 && power < 2.0
657 && total_var.is_finite()
658 && total_var > 0.0)
659 {
660 return None;
661 }
662 let phi_eff = total_var / mu.powf(power);
663 if !(phi_eff.is_finite() && phi_eff > 0.0) {
664 return None;
665 }
666 let q_lo = tweedie_quantile(p_lo, mu, phi_eff, power);
667 let q_hi = tweedie_quantile(p_hi, mu, phi_eff, power);
668 if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
669 Some((q_lo, q_hi))
670 } else {
671 None
672 }
673}
674
675/// Regularized lower incomplete gamma `P(a, x) = γ(a, x) / Γ(a)` — the CDF of a
676/// unit-scale `Gamma(shape = a)` variate — accurate down to the smallest
677/// representable `x`.
678///
679/// This is the exact function [`inverse_regularized_lower_gamma`] inverts, so we
680/// own it rather than borrowing `statrs::gamma_lr`. That routine hard-clamps to
681/// `0.0` for every `x ≤ 1.11e-15` (its `almost_eq(x, 0)` guard, with accuracy
682/// `DEFAULT_F64_ACC`), which silently zeroes the residual `P(a, x) − p` in the
683/// small-shape lower tail: the Halley iterate is then driven *up* — away from a
684/// good sub-`1e-15` seed — until `x` crosses that clamp around `~1.6e-15`, where
685/// the returned point carries far more mass than `p` (#1018). The Numerical
686/// Recipes split — a power series for `x < a + 1`, the modified-Lentz continued
687/// fraction for the complement `Q = 1 − P` otherwise — keeps the leading
688/// `exp(a·ln x − x − ln Γ(a))` factor in logs, so the value stays finite and
689/// nonzero for arguments far below that clamp, and always evaluates the *smaller*
690/// tail directly (no catastrophic cancellation near either edge).
691fn regularized_lower_gamma(a: f64, x: f64) -> f64 {
692 use statrs::function::gamma::ln_gamma;
693 // Callers (`inverse_regularized_lower_gamma`) validate `a > 0` upstream; a
694 // non-positive `a` would only mis-feed `ln_gamma`, never UB.
695 if x <= 0.0 {
696 return 0.0;
697 }
698 let gln = ln_gamma(a);
699 if x < a + 1.0 {
700 // Power series: P(a,x) = exp(a·ln x − x − ln Γ(a)) · Σ_{n≥0} xⁿ / Π_{k=0}^{n}(a+k).
701 // The running term `del` is the ratio form, so no factorial overflows.
702 let mut ap = a;
703 let mut del = 1.0 / a;
704 let mut sum = del;
705 for _ in 0..1000 {
706 ap += 1.0;
707 del *= x / ap;
708 sum += del;
709 if del.abs() <= sum.abs() * f64::EPSILON {
710 break;
711 }
712 }
713 (sum.ln() + a * x.ln() - x - gln).exp()
714 } else {
715 // Modified-Lentz continued fraction for Q(a,x) = 1 − P(a,x); P = 1 − Q.
716 // Evaluating the *upper* tail here keeps the directly-computed quantity
717 // small wherever P is near 1, so `1 − Q` loses no significant digits.
718 const FPMIN: f64 = 1e-300;
719 let mut b = x + 1.0 - a;
720 let mut c = 1.0 / FPMIN;
721 let mut d = 1.0 / b;
722 let mut h = d;
723 for i in 1..1000 {
724 let an = -(i as f64) * (i as f64 - a);
725 b += 2.0;
726 d = an * d + b;
727 if d.abs() < FPMIN {
728 d = FPMIN;
729 }
730 c = b + an / c;
731 if c.abs() < FPMIN {
732 c = FPMIN;
733 }
734 d = 1.0 / d;
735 let del = d * c;
736 h *= del;
737 if (del - 1.0).abs() <= f64::EPSILON {
738 break;
739 }
740 }
741 let q = (a * x.ln() - x - gln + h.ln()).exp();
742 1.0 - q
743 }
744}
745
746/// Inverse of the regularized lower incomplete gamma function: the `x ≥ 0` with
747/// `P(a, x) = p`, where `P(a, x) = γ(a, x) / Γ(a)` is the CDF of a unit-scale
748/// `Gamma(shape = a)` variate, `a > 0`, `p ∈ (0, 1)`.
749///
750/// Uses the standard rational/Wilson–Hilferty initial estimate, except in the
751/// extreme lower tail where the exact small-`x` seed
752/// `exp((ln p + ln Γ(a + 1)) / a)` follows from `P(a, x) ~ x^a / Γ(a + 1)`.
753/// For `a ≤ 1` it keeps the Numerical Recipes series/log initial estimate. The
754/// seed is refined by Halley's method on `P(a, x) − p` — third order, a Newton
755/// step scaled by the local curvature of `P`. The ratio `P(a, x)` is the crate's
756/// own [`regularized_lower_gamma`] (NOT `statrs::gamma_lr`, which clamps the
757/// residual to `−p` for tiny `x`; see that fn's note); the density
758/// `f(x) = x^{a−1} e^{−x} / Γ(a)` is evaluated through the same overflow-safe
759/// log factorization Numerical Recipes uses (`invgammp`), so the iteration stays
760/// finite across a wide range of `a`. A positivity step-halving guard keeps the
761/// iterate inside the support.
762fn inverse_regularized_lower_gamma(p: f64, a: f64) -> f64 {
763 use statrs::function::gamma::ln_gamma;
764
765 if !(a.is_finite() && a > 0.0) {
766 return f64::NAN;
767 }
768 if !p.is_finite() || p <= 0.0 {
769 return 0.0;
770 }
771 if p >= 1.0 {
772 return f64::INFINITY;
773 }
774
775 let gln = ln_gamma(a);
776 let a1 = a - 1.0;
777
778 // Initial estimate. For `a > 1` a Wilson–Hilferty transform of a normal
779 // quantile works away from the extreme lower tail; there, the small-`x`
780 // analytic seed is essentially exact. Both seeds feed the same Halley polish,
781 // so the crossover is continuous at the converged quantile.
782 let mut x = if a > 1.0 {
783 let pp = if p < 0.5 { p } else { 1.0 - p };
784 let t = (-2.0 * pp.ln()).sqrt();
785 let mut z = (2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t;
786 if p < 0.5 {
787 z = -z;
788 }
789 let wh_inner = 1.0 - 1.0 / (9.0 * a) - z / (3.0 * a.sqrt());
790 let wh_seed = if wh_inner > 0.0 {
791 a * wh_inner.powi(3)
792 } else {
793 f64::NAN
794 };
795 let analytic_seed = ((p.ln() + ln_gamma(a + 1.0)) / a).exp();
796 if analytic_seed == 0.0 {
797 return 0.0;
798 }
799 if !wh_seed.is_finite() || wh_seed <= 0.0 || wh_seed < 1.0e-2 || analytic_seed < 1.0e-2 {
800 analytic_seed
801 } else {
802 wh_seed
803 }
804 } else {
805 let t = 1.0 - a * (0.253 + a * 0.12);
806 if p < t {
807 (p / t).powf(1.0 / a)
808 } else {
809 1.0 - (1.0 - (p - t) / (1.0 - t)).ln()
810 }
811 };
812
813 // Density factorization constants for `a > 1` (kept overflow-safe in logs).
814 let (lna1, afac) = if a > 1.0 {
815 let lna1 = a1.ln();
816 (lna1, (a1 * (lna1 - 1.0) - gln).exp())
817 } else {
818 (0.0, 0.0)
819 };
820
821 // Halley refinement of the seeded quantile. Halley's cubic convergence
822 // reaches `f64` accuracy from the standard Wilson-Hilferty / asymptotic seed
823 // in only a few steps; this cap is a generous safety bound, not the expected
824 // iteration count, and the loop also exits early via the in-loop tolerance.
825 const MAX_HALLEY_STEPS: usize = 16;
826 for _ in 0..MAX_HALLEY_STEPS {
827 if x <= 0.0 {
828 return 0.0;
829 }
830 let err = regularized_lower_gamma(a, x) - p;
831 let dens = if a > 1.0 {
832 afac * (-(x - a1) + a1 * (x.ln() - lna1)).exp()
833 } else {
834 (-x + a1 * x.ln() - gln).exp()
835 };
836 if !(dens.is_finite() && dens > 0.0) {
837 break;
838 }
839 // Newton step `u = (P(a,x) − p) / f(x)`, then the Halley scaling by the
840 // local curvature `f'/f = (a−1)/x − 1`, capped (per NR) so the
841 // denominator never collapses below ½.
842 let u = err / dens;
843 let step = u / (1.0 - 0.5 * (u * (a1 / x - 1.0)).min(1.0));
844 x -= step;
845 if x <= 0.0 {
846 // Overshot the support floor: step back to half the prior iterate.
847 x = 0.5 * (x + step);
848 }
849 if step.abs() < 1.0e-12 * x.max(1.0e-300) {
850 break;
851 }
852 }
853 x
854}
855
856/// Inverse-link transform per likelihood specification (response scale).
857///
858/// Uses the EXACT public inverse-link jet, so the log link reports `exp(η)`
859/// (finite wherever representable) rather than the solver's clamped value
860/// (issue #963).
861#[inline]
862pub fn try_inverse_link_array(
863 likelihood: &LikelihoodSpec,
864 eta: ArrayView1<'_, f64>,
865) -> Result<Array1<f64>, EstimationError> {
866 let mut out = Array1::<f64>::zeros(eta.len());
867 for i in 0..eta.len() {
868 out[i] = inverse_link_jet_for_family_public(likelihood, eta[i])?.mu;
869 }
870 Ok(out)
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use gam_solve::mixture_link::{state_from_sasspec, state_fromspec};
877 use gam_problem::types::{
878 InverseLink, LinkComponent, MixtureLinkSpec, ResponseFamily, SasLinkSpec, StandardLink,
879 };
880 use ndarray::array;
881
882 #[test]
883 fn signed_log_sum_exp_propagates_positive_infinities() {
884 // A single +∞ positive-sign term dominates ⇒ S = +∞ ⇒ (+∞, +1).
885 let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[1.0]);
886 assert_eq!(lm, f64::INFINITY);
887 assert_eq!(s, 1.0);
888
889 // A single +∞ negative-sign term ⇒ S = −∞, encoded as (+∞, −1).
890 let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[-1.0]);
891 assert_eq!(lm, f64::INFINITY);
892 assert_eq!(s, -1.0);
893
894 // +∞ on both signs ⇒ indeterminate +∞ − ∞ ⇒ (NaN, 0).
895 let (lm, s) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
896 assert!(lm.is_nan());
897 assert_eq!(s, 0.0);
898
899 // A finite positive term alongside a +∞ positive term still gives +∞.
900 let (lm, s) = signed_log_sum_exp(&[0.0, f64::INFINITY], &[1.0, 1.0]);
901 assert_eq!(lm, f64::INFINITY);
902 assert_eq!(s, 1.0);
903
904 // −∞ log-magnitudes are exp(−∞)=0 and must be dropped: mixing a finite
905 // term with a −∞ term reproduces the lone finite term unchanged.
906 let (lm, s) = signed_log_sum_exp(&[2.0, f64::NEG_INFINITY], &[1.0, -1.0]);
907 assert!((lm - 2.0).abs() < 1e-12);
908 assert_eq!(s, 1.0);
909
910 // Finite sanity check: exp(ln 3) − exp(ln 1) = 2 ⇒ (ln 2, +1).
911 let (lm, s) = signed_log_sum_exp(&[3.0_f64.ln(), 1.0_f64.ln()], &[1.0, -1.0]);
912 assert!((lm - 2.0_f64.ln()).abs() < 1e-12);
913 assert_eq!(s, 1.0);
914 }
915
916 #[test]
917 fn standard_inverse_link_specs_evaluate() {
918 let eta = array![0.1, -0.2, 0.3];
919 let likelihood = LikelihoodSpec::new(
920 ResponseFamily::Binomial,
921 InverseLink::Standard(StandardLink::Logit),
922 );
923 let mu = try_inverse_link_array(&likelihood, eta.view()).expect("standard logit spec");
924 assert_eq!(mu.len(), eta.len());
925 assert!(mu.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
926 }
927
928 #[test]
929 fn sas_and_mixture_stateful_inverse_link_evaluates() {
930 let eta = array![0.1, -0.2, 0.3];
931 let sas_likelihood = LikelihoodSpec::new(
932 ResponseFamily::Binomial,
933 InverseLink::Sas(
934 state_from_sasspec(SasLinkSpec {
935 initial_epsilon: 0.2,
936 initial_log_delta: -0.1,
937 })
938 .expect("sas state"),
939 ),
940 );
941 let sas = try_inverse_link_array(&sas_likelihood, eta.view()).expect("SAS with params");
942 assert_eq!(sas.len(), eta.len());
943 assert!(sas.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
944
945 let spec = MixtureLinkSpec {
946 components: vec![LinkComponent::Probit, LinkComponent::CLogLog],
947 initial_rho: array![0.3],
948 };
949 let state = state_fromspec(&spec).expect("mixture state");
950 let mix_likelihood =
951 LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Mixture(state));
952 let mix = try_inverse_link_array(&mix_likelihood, eta.view()).expect("mixture with state");
953 assert_eq!(mix.len(), eta.len());
954 assert!(mix.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
955 }
956
957 #[test]
958 fn gamma_quantile_matches_known_reference_values() {
959 // Reference quantiles for unit-scale Gamma(shape=a) from the regularized
960 // lower incomplete gamma inverse (cross-checked against scipy
961 // `gamma.ppf(p, a)` to ~1e-6). Spanning a < 1, a = 1 (exponential), and
962 // a ≫ 1 exercises every initial-estimate / density branch.
963 let cases: [(f64, f64, f64); 9] = [
964 // (p, shape a, expected unit-scale quantile)
965 (0.025, 4.0, 1.089_865_4),
966 (0.5, 4.0, 3.672_060_4),
967 (0.975, 4.0, 8.767_273_4),
968 (0.025, 1.0, 0.025_317_8), // Exp(1): -ln(1-p)
969 (0.975, 1.0, 3.688_879_4),
970 (0.5, 0.5, 0.227_468_2),
971 (0.99, 0.5, 3.317_448_3),
972 (0.025, 50.0, 37.110_963_7),
973 (0.975, 50.0, 64.780_598_6),
974 ];
975 for (p, a, expected) in cases {
976 let got = gamma_quantile(p, a, 1.0);
977 let rel = (got - expected).abs() / expected.max(1e-12);
978 assert!(
979 rel < 1e-4,
980 "gamma_quantile(p={p}, a={a}) = {got}, expected ≈ {expected} (rel err {rel})"
981 );
982 }
983 }
984
985 #[test]
986 fn gamma_quantile_handles_extreme_lower_tail_for_shape_two() {
987 let got = gamma_quantile(1.0e-300, 2.0, 1.0);
988 let expected = 1.414_213_562_373_095_1e-150;
989 let rel = (got - expected).abs() / expected;
990 assert!(
991 rel < 1.0e-6,
992 "gamma_quantile(1e-300, 2, 1) = {got}, expected {expected} (rel err {rel})"
993 );
994 }
995
996 #[test]
997 fn gamma_quantile_round_trips_extreme_lower_tail_for_shape_above_one() {
998 for &a in &[1.5_f64, 2.0, 5.0, 20.0] {
999 for &p in &[1.0e-300_f64, 1.0e-100, 1.0e-12, 1.0e-3, 0.5, 0.999] {
1000 let x = gamma_quantile(p, a, 1.0);
1001 assert!(
1002 x.is_finite() && x >= 0.0,
1003 "non-finite quantile a={a} p={p}: {x}"
1004 );
1005 let recovered = regularized_lower_gamma(a, x);
1006 let rel = (recovered - p).abs() / p;
1007 assert!(
1008 rel < 1.0e-6,
1009 "round-trip failed a={a} p={p}: q={x}, P(a,q)={recovered}, rel err {rel}"
1010 );
1011 }
1012 }
1013 }
1014
1015 #[test]
1016 fn gamma_quantile_is_consistent_with_the_cdf_round_trip() {
1017 // The inverse must invert the CDF: P(a, Q(p; a)) = p. Verify across a
1018 // grid of shapes and probabilities using statrs `gamma_lr` as the CDF.
1019 use statrs::function::gamma::gamma_lr;
1020 for &a in &[0.3_f64, 0.75, 1.0, 2.5, 10.0, 80.0] {
1021 for &p in &[0.001_f64, 0.01, 0.025, 0.25, 0.5, 0.75, 0.975, 0.99, 0.999] {
1022 let x = gamma_quantile(p, a, 1.0);
1023 assert!(
1024 x.is_finite() && x > 0.0,
1025 "non-finite quantile a={a} p={p}: {x}"
1026 );
1027 let recovered = gamma_lr(a, x);
1028 assert!(
1029 (recovered - p).abs() < 1e-6,
1030 "CDF round-trip failed a={a} p={p}: P(a, {x}) = {recovered}"
1031 );
1032 }
1033 }
1034 }
1035
1036 #[test]
1037 fn regularized_lower_gamma_is_accurate_and_unclamped_below_statrs_floor() {
1038 use statrs::function::gamma::{gamma_lr, ln_gamma};
1039
1040 // (1) Agrees with statrs `gamma_lr` everywhere statrs is itself valid
1041 // (arguments well above its `x ≤ 1.11e-15` clamp), across both the
1042 // series (x < a+1) and continued-fraction (x ≥ a+1) branches.
1043 for &a in &[0.05_f64, 0.3, 1.0, 2.5, 50.0] {
1044 for &x in &[1e-6_f64, 0.01, 0.5, 1.0, 3.0, 25.0, 120.0] {
1045 let ours = regularized_lower_gamma(a, x);
1046 let theirs = gamma_lr(a, x);
1047 assert!(
1048 (ours - theirs).abs() < 1e-12,
1049 "P({a},{x}): ours={ours} statrs={theirs}"
1050 );
1051 assert!(
1052 (0.0..=1.0).contains(&ours),
1053 "P({a},{x})={ours} out of [0,1]"
1054 );
1055 }
1056 }
1057
1058 // (2) Exp(1) closed form P(1, x) = 1 − e^{−x}.
1059 for &x in &[1e-3_f64, 0.25, 2.0, 9.0] {
1060 assert!((regularized_lower_gamma(1.0, x) - (1.0 - (-x).exp())).abs() < 1e-13);
1061 }
1062
1063 // (3) The regression heart of #1018: for x far below statrs's clamp the
1064 // CDF must remain a faithful, nonzero value, not snap to 0. Compare to
1065 // the small-x leading order P(a, x) ≈ x^a / Γ(a+1).
1066 for &(a, x) in &[(0.05_f64, 1e-20_f64), (0.1, 1e-25), (0.02, 1e-40)] {
1067 assert_eq!(
1068 gamma_lr(a, x),
1069 0.0,
1070 "precondition: statrs clamps P({a},{x}) to 0"
1071 );
1072 let ours = regularized_lower_gamma(a, x);
1073 let leading = (a * x.ln() - ln_gamma(a + 1.0)).exp();
1074 assert!(ours > 0.0, "P({a},{x})={ours} clamped to 0 like statrs");
1075 assert!(
1076 (ours - leading).abs() < 1e-9 * leading,
1077 "P({a},{x})={ours}, leading order {leading}"
1078 );
1079 }
1080 }
1081
1082 #[test]
1083 fn gamma_quantile_scale_and_monotonicity() {
1084 // Scale is a pure multiplier, and the quantile is strictly increasing
1085 // in p (an equal-tailed interval must order correctly).
1086 let q_unit = gamma_quantile(0.9, 3.0, 1.0);
1087 let q_scaled = gamma_quantile(0.9, 3.0, 7.5);
1088 assert!((q_scaled - 7.5 * q_unit).abs() < 1e-9 * q_scaled.max(1.0));
1089
1090 let mut prev = 0.0;
1091 for i in 1..100 {
1092 let p = i as f64 / 100.0;
1093 let q = gamma_quantile(p, 2.0, 1.0);
1094 assert!(q > prev, "quantile not increasing at p={p}: {q} <= {prev}");
1095 prev = q;
1096 }
1097 }
1098
1099 #[test]
1100 fn gamma_quantile_rejects_degenerate_parameters() {
1101 assert!(gamma_quantile(0.5, -1.0, 1.0).is_nan());
1102 assert!(gamma_quantile(0.5, 1.0, 0.0).is_nan());
1103 assert!(gamma_quantile(0.5, f64::NAN, 1.0).is_nan());
1104 assert_eq!(gamma_quantile(0.0, 2.0, 1.0), 0.0);
1105 assert_eq!(gamma_quantile(-0.1, 2.0, 1.0), 0.0);
1106 assert!(gamma_quantile(1.0, 2.0, 1.0).is_infinite());
1107 }
1108
1109 #[test]
1110 fn gamma_moment_matched_interval_is_the_exact_conditional_gamma_when_se_vanishes() {
1111 // With no estimation uncertainty the total predictive variance is the
1112 // pure observation noise `Var(Y|μ) = φμ²`, and the moment-matched Gamma
1113 // must coincide *exactly* with the conditional `Gamma(shape = 1/φ,
1114 // scale = φμ)` (#817). Check against the analytic Gamma quantiles for a
1115 // shape-4 (φ = 0.25) Gamma at the equal-tailed 2.5%/97.5% levels.
1116 let phi = 0.25_f64; // shape k = 1/φ = 4
1117 let mu = 7.5_f64;
1118 let total_var = phi * mu * mu; // SE(μ̂) = 0
1119 let (lo, hi) = gamma_moment_matched_interval(mu, total_var, 0.025, 0.975)
1120 .expect("non-degenerate moment-matched Gamma interval");
1121
1122 let analytic_lo = gamma_quantile(0.025, 1.0 / phi, phi * mu);
1123 let analytic_hi = gamma_quantile(0.975, 1.0 / phi, phi * mu);
1124 assert!(
1125 (lo - analytic_lo).abs() < 1e-9 * analytic_lo.max(1.0)
1126 && (hi - analytic_hi).abs() < 1e-9 * analytic_hi.max(1.0),
1127 "moment-matched interval [{lo}, {hi}] != conditional Gamma \
1128 [{analytic_lo}, {analytic_hi}]"
1129 );
1130 }
1131
1132 #[test]
1133 fn gamma_moment_matched_interval_is_right_skewed_not_symmetric() {
1134 // The whole point of #817: for a right-skewed Gamma the equal-tailed
1135 // band is *asymmetric* about the mean — the upper gap exceeds the lower
1136 // gap — and the lower edge sits FAR above the symmetric-band edge
1137 // `μ·(1 − z/√k)`, which for shape 4 hugs the support floor at ≈ 0.02·μ.
1138 let phi = 0.25_f64; // shape 4, CV = 0.5
1139 let mu = 10.0_f64;
1140 let total_var = phi * mu * mu;
1141 let z = 1.959_963_984_540_054_f64; // 97.5% standard-normal quantile
1142 let (lo, hi) =
1143 gamma_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
1144
1145 // Ordered, strictly positive, brackets the mean.
1146 assert!(
1147 0.0 < lo && lo < mu && mu < hi,
1148 "interval [{lo}, {hi}] ∌ μ={mu}"
1149 );
1150 // Right skew: the upper gap is the larger one.
1151 let lower_gap = mu - lo;
1152 let upper_gap = hi - mu;
1153 assert!(
1154 upper_gap > 1.3 * lower_gap,
1155 "expected a right-skewed band (upper gap ≫ lower gap), got \
1156 lower_gap={lower_gap}, upper_gap={upper_gap}"
1157 );
1158 // The symmetric lower edge would be μ·(1 − z·√φ) = 10·(1 − 1.96·0.5) ≈
1159 // 0.20 — essentially the support floor. The skew-correct lower edge sits
1160 // well above it (true Gamma 2.5% quantile ≈ 0.27·μ for shape 4).
1161 let symmetric_lower = mu * (1.0 - z * phi.sqrt());
1162 assert!(
1163 lo > 2.0 * symmetric_lower.max(0.0) + 1.0,
1164 "skew-correct lower edge {lo} should sit well above the symmetric \
1165 edge {symmetric_lower}"
1166 );
1167 }
1168
1169 #[test]
1170 fn gamma_moment_matched_interval_widens_with_estimation_uncertainty() {
1171 // Adding estimation variance SE(μ̂)² to the observation noise must widen
1172 // the predictive band (lower edge down, upper edge up) — it is the
1173 // moment-matched predictive, not just the conditional law.
1174 let phi = 0.25_f64;
1175 let mu = 5.0_f64;
1176 let obs_var = phi * mu * mu;
1177 let (lo0, hi0) = gamma_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
1178 let (lo1, hi1) = gamma_moment_matched_interval(mu, obs_var + 4.0, 0.025, 0.975).unwrap();
1179 assert!(
1180 lo1 < lo0 && hi1 > hi0,
1181 "estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1182 );
1183 }
1184
1185 #[test]
1186 fn gamma_moment_matched_interval_rejects_degenerate_and_near_gaussian_inputs() {
1187 // Non-positive mean / variance, or non-finite inputs => None (caller
1188 // falls back to the symmetric Gaussian edges).
1189 assert!(gamma_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
1190 assert!(gamma_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
1191 assert!(gamma_moment_matched_interval(1.0, 0.0, 0.025, 0.975).is_none());
1192 assert!(gamma_moment_matched_interval(1.0, -1.0, 0.025, 0.975).is_none());
1193 assert!(gamma_moment_matched_interval(f64::NAN, 1.0, 0.025, 0.975).is_none());
1194 assert!(gamma_moment_matched_interval(1.0, f64::INFINITY, 0.025, 0.975).is_none());
1195 // A finite, well-conditioned case still returns Some.
1196 assert!(gamma_moment_matched_interval(3.0, 2.0, 0.025, 0.975).is_some());
1197 }
1198
1199 #[test]
1200 fn beta_quantile_matches_known_reference_values() {
1201 // Reference Beta quantiles cross-checked against scipy `beta.ppf(p,a,b)`.
1202 // Spans symmetric (a=b), left-skewed (a<b), right-skewed (a>b), and a
1203 // high-precision case to exercise the inverse-incomplete-beta branches.
1204 let cases: [(f64, f64, f64, f64); 8] = [
1205 // (p, a, b, expected) — scipy `beta.ppf`.
1206 (0.025, 2.0, 2.0, 0.094_299_3),
1207 (0.975, 2.0, 2.0, 0.905_700_7),
1208 (0.5, 2.0, 2.0, 0.5),
1209 (0.025, 0.8, 4.0, 0.002_339_1),
1210 (0.975, 0.8, 4.0, 0.564_717_3),
1211 (0.025, 5.0, 1.5, 0.408_549_1),
1212 (0.5, 20.0, 80.0, 0.197_994_8),
1213 (0.975, 20.0, 80.0, 0.283_367_6),
1214 ];
1215 for (p, a, b, expected) in cases {
1216 let got = beta_quantile(p, a, b);
1217 let abs = (got - expected).abs();
1218 assert!(
1219 abs < 1e-5,
1220 "beta_quantile(p={p}, a={a}, b={b}) = {got}, expected ≈ {expected} (abs err {abs})"
1221 );
1222 }
1223 }
1224
1225 #[test]
1226 fn beta_quantile_boundaries_and_degeneracy() {
1227 // p at/over the support boundaries map to 0 / 1; bad shapes => NaN; and
1228 // the quantile is strictly increasing in p.
1229 assert_eq!(beta_quantile(0.0, 2.0, 3.0), 0.0);
1230 assert_eq!(beta_quantile(-0.5, 2.0, 3.0), 0.0);
1231 assert_eq!(beta_quantile(1.0, 2.0, 3.0), 1.0);
1232 assert_eq!(beta_quantile(1.5, 2.0, 3.0), 1.0);
1233 assert!(beta_quantile(0.5, -1.0, 3.0).is_nan());
1234 assert!(beta_quantile(0.5, 2.0, 0.0).is_nan());
1235 assert!(beta_quantile(0.5, f64::NAN, 3.0).is_nan());
1236 let mut prev = 0.0;
1237 for i in 1..100 {
1238 let p = i as f64 / 100.0;
1239 let q = beta_quantile(p, 3.0, 5.0);
1240 assert!(
1241 q > prev,
1242 "beta quantile not increasing at p={p}: {q} <= {prev}"
1243 );
1244 prev = q;
1245 }
1246 }
1247
1248 #[test]
1249 fn beta_moment_matched_interval_is_the_exact_conditional_beta_when_se_vanishes() {
1250 // With no estimation uncertainty the total predictive variance is the
1251 // pure observation noise `μ(1−μ)/(1+φ)`, and the moment-matched Beta must
1252 // coincide *exactly* with the conditional `Beta(μφ, (1−μ)φ)` (#1194).
1253 let phi = 8.0_f64;
1254 let mu = 0.2_f64;
1255 let total_var = mu * (1.0 - mu) / (1.0 + phi); // SE(μ̂) = 0
1256 let (lo, hi) = beta_moment_matched_interval(mu, total_var, 0.025, 0.975)
1257 .expect("non-degenerate moment-matched Beta interval");
1258 let analytic_lo = beta_quantile(0.025, mu * phi, (1.0 - mu) * phi);
1259 let analytic_hi = beta_quantile(0.975, mu * phi, (1.0 - mu) * phi);
1260 assert!(
1261 (lo - analytic_lo).abs() < 1e-9 && (hi - analytic_hi).abs() < 1e-9,
1262 "moment-matched interval [{lo}, {hi}] != conditional Beta [{analytic_lo}, {analytic_hi}]"
1263 );
1264 }
1265
1266 #[test]
1267 fn beta_moment_matched_interval_is_skewed_not_symmetric() {
1268 // For a small-mean Beta the equal-tailed band is asymmetric about μ (the
1269 // upper gap exceeds the lower gap) and the lower edge sits well above the
1270 // symmetric edge `μ − z·σ`, which on this data dives below 0.
1271 let phi = 8.0_f64;
1272 let mu = 0.15_f64;
1273 let total_var = mu * (1.0 - mu) / (1.0 + phi);
1274 let z = 1.959_963_984_540_054_f64;
1275 let (lo, hi) =
1276 beta_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
1277 assert!(
1278 0.0 < lo && lo < mu && mu < hi && hi < 1.0,
1279 "interval [{lo},{hi}] ∌ μ={mu}"
1280 );
1281 let lower_gap = mu - lo;
1282 let upper_gap = hi - mu;
1283 assert!(
1284 upper_gap > 1.2 * lower_gap,
1285 "expected a right-skewed band (upper gap > lower gap): lower={lower_gap}, upper={upper_gap}"
1286 );
1287 let symmetric_lower = mu - z * total_var.sqrt();
1288 assert!(
1289 symmetric_lower < 0.0 && lo > 0.0,
1290 "skew-correct lower edge {lo} should stay positive where the symmetric edge {symmetric_lower} goes negative"
1291 );
1292 }
1293
1294 #[test]
1295 fn beta_moment_matched_interval_rejects_degenerate_and_over_dispersed_inputs() {
1296 // Mean outside (0,1), non-positive variance, non-finite => None.
1297 assert!(beta_moment_matched_interval(0.0, 0.01, 0.025, 0.975).is_none());
1298 assert!(beta_moment_matched_interval(1.0, 0.01, 0.025, 0.975).is_none());
1299 assert!(beta_moment_matched_interval(-0.1, 0.01, 0.025, 0.975).is_none());
1300 assert!(beta_moment_matched_interval(0.3, 0.0, 0.025, 0.975).is_none());
1301 assert!(beta_moment_matched_interval(f64::NAN, 0.01, 0.025, 0.975).is_none());
1302 // Variance at/over the Bernoulli ceiling μ(1−μ): no Beta matches => None.
1303 assert!(beta_moment_matched_interval(0.5, 0.25, 0.025, 0.975).is_none());
1304 assert!(beta_moment_matched_interval(0.5, 0.30, 0.025, 0.975).is_none());
1305 // A well-conditioned case still returns Some.
1306 assert!(beta_moment_matched_interval(0.4, 0.02, 0.025, 0.975).is_some());
1307 }
1308
1309 #[test]
1310 fn beta_moment_matched_interval_widens_with_estimation_uncertainty() {
1311 let phi = 8.0_f64;
1312 let mu = 0.3_f64;
1313 let obs_var = mu * (1.0 - mu) / (1.0 + phi);
1314 let (lo0, hi0) = beta_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
1315 let (lo1, hi1) = beta_moment_matched_interval(mu, obs_var + 0.01, 0.025, 0.975).unwrap();
1316 assert!(
1317 lo1 < lo0 && hi1 > hi0,
1318 "estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1319 );
1320 }
1321
1322 #[test]
1323 fn negative_binomial_quantile_matches_known_reference_values() {
1324 // Reference NB quantiles cross-checked against scipy
1325 // `nbinom.ppf(p, n=θ, prob=θ/(θ+μ))` — the integer count k with the
1326 // smallest CDF ≥ p. Spans the zero-atom lower tail, the right-skewed
1327 // upper tail, and a larger-mean near-Gaussian case.
1328 let cases: [(f64, f64, f64, f64); 8] = [
1329 // (p, μ, θ, expected integer quantile)
1330 (0.025, 1.6, 1.5, 0.0), // zero mass ≈ 0.34 > 0.025 ⇒ lower edge 0
1331 (0.5, 1.6, 1.5, 1.0),
1332 (0.975, 1.6, 1.5, 6.0),
1333 (0.99, 1.6, 1.5, 8.0),
1334 (0.025, 20.0, 5.0, 5.0),
1335 (0.975, 20.0, 5.0, 43.0),
1336 (0.5, 20.0, 5.0, 19.0),
1337 (0.975, 0.5, 2.0, 3.0),
1338 ];
1339 for (p, mu, theta, expected) in cases {
1340 let got = negative_binomial_quantile(p, mu, theta);
1341 assert_eq!(
1342 got, expected,
1343 "negative_binomial_quantile(p={p}, μ={mu}, θ={theta}) = {got}, expected {expected}"
1344 );
1345 }
1346 }
1347
1348 #[test]
1349 fn negative_binomial_quantile_is_a_valid_cdf_inverse() {
1350 // The returned integer k must be the *smallest* with CDF(k) ≥ p:
1351 // CDF(k) ≥ p and (for k ≥ 1) CDF(k−1) < p, across a grid of (μ, θ, p).
1352 use statrs::function::beta::beta_reg;
1353 for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
1354 for &theta in &[0.5_f64, 1.5, 5.0, 40.0] {
1355 let prob = theta / (theta + mu);
1356 for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
1357 let k = negative_binomial_quantile(p, mu, theta);
1358 assert!(
1359 k.is_finite() && k >= 0.0 && k.fract() == 0.0,
1360 "non-integer k={k}"
1361 );
1362 let cdf_k = beta_reg(theta, k + 1.0, prob);
1363 assert!(
1364 cdf_k + 1e-12 >= p,
1365 "CDF({k}) = {cdf_k} < p = {p} (μ={mu}, θ={theta})"
1366 );
1367 if k >= 1.0 {
1368 let cdf_below = beta_reg(theta, k, prob);
1369 assert!(
1370 cdf_below < p,
1371 "k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu}, θ={theta})",
1372 k - 1.0
1373 );
1374 }
1375 }
1376 }
1377 }
1378 }
1379
1380 #[test]
1381 fn negative_binomial_quantile_boundaries_and_degeneracy() {
1382 assert_eq!(negative_binomial_quantile(0.0, 2.0, 1.5), 0.0);
1383 assert_eq!(negative_binomial_quantile(-0.1, 2.0, 1.5), 0.0);
1384 assert!(negative_binomial_quantile(1.0, 2.0, 1.5).is_infinite());
1385 assert_eq!(negative_binomial_quantile(0.5, 0.0, 1.5), 0.0); // point mass at 0
1386 assert!(negative_binomial_quantile(0.5, -1.0, 1.5).is_nan());
1387 assert!(negative_binomial_quantile(0.5, 2.0, 0.0).is_nan());
1388 assert!(negative_binomial_quantile(0.5, 2.0, f64::NAN).is_nan());
1389 // Monotone non-decreasing in p (discrete ⇒ plateaus allowed).
1390 let mut prev = 0.0;
1391 for i in 1..100 {
1392 let p = i as f64 / 100.0;
1393 let q = negative_binomial_quantile(p, 4.0, 2.0);
1394 assert!(q >= prev, "NB quantile decreased at p={p}: {q} < {prev}");
1395 prev = q;
1396 }
1397 }
1398
1399 #[test]
1400 fn negative_binomial_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
1401 // SE(μ̂) = 0 ⇒ total_var = μ + μ²/θ ⇒ θ_eff = θ, recovering the exact
1402 // conditional NB quantiles.
1403 let mu = 1.6_f64;
1404 let theta = 1.5_f64;
1405 let total_var = mu + mu * mu / theta;
1406 let (lo, hi) =
1407 negative_binomial_moment_matched_interval(mu, theta, total_var, 0.025, 0.975).unwrap();
1408 assert_eq!(lo, negative_binomial_quantile(0.025, mu, theta));
1409 assert_eq!(hi, negative_binomial_quantile(0.975, mu, theta));
1410 }
1411
1412 #[test]
1413 fn negative_binomial_moment_matched_interval_widens_with_estimation_uncertainty() {
1414 // Adding estimation variance lowers θ_eff (more overdispersion) and must
1415 // not shrink the band; with enough added variance the upper edge grows.
1416 let mu = 8.0_f64;
1417 let theta = 4.0_f64;
1418 let obs_var = mu + mu * mu / theta;
1419 let (lo0, hi0) =
1420 negative_binomial_moment_matched_interval(mu, theta, obs_var, 0.025, 0.975).unwrap();
1421 let (lo1, hi1) =
1422 negative_binomial_moment_matched_interval(mu, theta, obs_var + 40.0, 0.025, 0.975)
1423 .unwrap();
1424 assert!(
1425 lo1 <= lo0 && hi1 > hi0,
1426 "band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1427 );
1428 }
1429
1430 #[test]
1431 fn negative_binomial_moment_matched_interval_rejects_degenerate_inputs() {
1432 assert!(negative_binomial_moment_matched_interval(0.0, 1.5, 1.0, 0.025, 0.975).is_none());
1433 assert!(negative_binomial_moment_matched_interval(-1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1434 assert!(negative_binomial_moment_matched_interval(2.0, 0.0, 1.0, 0.025, 0.975).is_none());
1435 assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 0.0, 0.025, 0.975).is_none());
1436 assert!(
1437 negative_binomial_moment_matched_interval(f64::NAN, 1.5, 1.0, 0.025, 0.975).is_none()
1438 );
1439 assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 6.0, 0.025, 0.975).is_some());
1440 }
1441
1442 #[test]
1443 fn poisson_quantile_matches_known_reference_values() {
1444 // Reference integer quantiles from scipy.stats.poisson.ppf.
1445 let cases: [(f64, f64, f64); 9] = [
1446 // (p, μ, expected integer quantile)
1447 (0.025, 1.6, 0.0), // zero mass e^{−1.6} ≈ 0.20 < 0.025? no: 0.20 > 0.025 ⇒ 0
1448 (0.5, 1.6, 1.0),
1449 (0.975, 1.6, 4.0),
1450 (0.99, 1.6, 5.0),
1451 (0.025, 20.0, 12.0),
1452 (0.975, 20.0, 29.0),
1453 (0.5, 20.0, 20.0),
1454 (0.975, 0.5, 2.0),
1455 (0.025, 0.5, 0.0),
1456 ];
1457 for (p, mu, expected) in cases {
1458 let got = poisson_quantile(p, mu);
1459 assert_eq!(
1460 got, expected,
1461 "poisson_quantile(p={p}, μ={mu}) = {got}, expected {expected}"
1462 );
1463 }
1464 }
1465
1466 #[test]
1467 fn poisson_quantile_is_a_valid_cdf_inverse() {
1468 // The returned integer k must be the *smallest* with CDF(k) ≥ p:
1469 // CDF(k) ≥ p and (for k ≥ 1) CDF(k−1) < p, across a grid of (μ, p).
1470 for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
1471 for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
1472 let k = poisson_quantile(p, mu);
1473 assert!(
1474 k.is_finite() && k >= 0.0 && k.fract() == 0.0,
1475 "non-integer k={k}"
1476 );
1477 let cdf_k = poisson_cdf_at(k, mu);
1478 assert!(cdf_k + 1e-12 >= p, "CDF({k}) = {cdf_k} < p = {p} (μ={mu})");
1479 if k >= 1.0 {
1480 let cdf_below = poisson_cdf_at(k - 1.0, mu);
1481 assert!(
1482 cdf_below < p,
1483 "k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu})",
1484 k - 1.0
1485 );
1486 }
1487 }
1488 }
1489 }
1490
1491 #[test]
1492 fn poisson_quantile_boundaries_and_degeneracy() {
1493 assert_eq!(poisson_quantile(0.0, 2.0), 0.0);
1494 assert_eq!(poisson_quantile(-0.1, 2.0), 0.0);
1495 assert!(poisson_quantile(1.0, 2.0).is_infinite());
1496 assert_eq!(poisson_quantile(0.5, 0.0), 0.0); // point mass at 0
1497 assert!(poisson_quantile(0.5, -1.0).is_nan());
1498 assert!(poisson_quantile(0.5, f64::NAN).is_nan());
1499 // Monotone non-decreasing in p (discrete ⇒ plateaus allowed).
1500 let mut prev = 0.0;
1501 for i in 1..100 {
1502 let p = i as f64 / 100.0;
1503 let q = poisson_quantile(p, 4.0);
1504 assert!(
1505 q >= prev,
1506 "Poisson quantile decreased at p={p}: {q} < {prev}"
1507 );
1508 prev = q;
1509 }
1510 }
1511
1512 #[test]
1513 fn poisson_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
1514 // SE(μ̂) = 0 ⇒ total_var = μ ⇒ θ_eff = ∞, recovering the exact conditional
1515 // Poisson quantiles directly (no NB widening).
1516 for &mu in &[0.5_f64, 1.6, 20.0] {
1517 let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
1518 assert_eq!(lo, poisson_quantile(0.025, mu));
1519 assert_eq!(hi, poisson_quantile(0.975, mu));
1520 }
1521 }
1522
1523 #[test]
1524 fn poisson_moment_matched_interval_widens_with_estimation_uncertainty() {
1525 // Adding estimation variance lowers θ_eff (genuine overdispersion) and
1526 // must not shrink the band; with enough added variance the upper edge
1527 // grows beyond the conditional Poisson quantile.
1528 let mu = 20.0_f64;
1529 let (lo0, hi0) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
1530 let (lo1, hi1) = poisson_moment_matched_interval(mu, mu + 40.0, 0.025, 0.975).unwrap();
1531 assert!(
1532 lo1 <= lo0 && hi1 > hi0,
1533 "band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1534 );
1535 // A negligible excess (θ_eff above the switch threshold) must coincide
1536 // with the exact conditional Poisson — no discontinuity at the boundary.
1537 let (lo2, hi2) =
1538 poisson_moment_matched_interval(mu, mu + mu * mu * 1.0e-12, 0.025, 0.975).unwrap();
1539 assert_eq!((lo2, hi2), (lo0, hi0));
1540 }
1541
1542 #[test]
1543 fn poisson_moment_matched_interval_is_skewed_not_symmetric() {
1544 // The whole point of #1193/#817: on a low-rate count the equal-tailed
1545 // upper edge sits ABOVE the symmetric `μ + z·√μ` band that under-covers
1546 // the upper tail, and the band is asymmetric about μ.
1547 let mu = 2.0_f64;
1548 let z = standard_normal_quantile(0.975).unwrap();
1549 let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
1550 let sym_hi = mu + z * mu.sqrt();
1551 assert!(
1552 hi > sym_hi,
1553 "equal-tailed upper {hi} should exceed symmetric upper {sym_hi}"
1554 );
1555 // Upper tail reaches further from μ than the lower tail (right skew).
1556 assert!(
1557 (hi - mu) > (mu - lo),
1558 "band not right-skewed: lo={lo}, hi={hi}, μ={mu}"
1559 );
1560 }
1561
1562 #[test]
1563 fn poisson_moment_matched_interval_rejects_degenerate_inputs() {
1564 assert!(poisson_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
1565 assert!(poisson_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
1566 assert!(poisson_moment_matched_interval(2.0, 0.0, 0.025, 0.975).is_none());
1567 assert!(poisson_moment_matched_interval(2.0, 1.0, 0.025, 0.975).is_none()); // total_var < μ
1568 assert!(poisson_moment_matched_interval(f64::NAN, 5.0, 0.025, 0.975).is_none());
1569 assert!(poisson_moment_matched_interval(2.0, 5.0, 0.025, 0.975).is_some());
1570 }
1571
1572 #[test]
1573 fn tweedie_quantile_is_a_valid_cdf_inverse() {
1574 // For a probability strictly above the zero atom the quantile `y` must
1575 // satisfy `CDF(y) ≈ q`: the bisection inverts `tweedie_cdf_at` exactly.
1576 let mu = 3.0_f64;
1577 let phi = 1.2_f64;
1578 let power = 1.5_f64;
1579 let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
1580 let zero_mass = (-lambda).exp();
1581 for &q in &[0.30_f64, 0.5, 0.75, 0.9, 0.975, 0.99] {
1582 assert!(
1583 q > zero_mass,
1584 "test q must exceed the zero atom {zero_mass}"
1585 );
1586 let y = tweedie_quantile(q, mu, phi, power);
1587 assert!(y.is_finite() && y > 0.0, "quantile out of support: {y}");
1588 let cdf = tweedie_cdf_at(y, mu, phi, power);
1589 assert!((cdf - q).abs() < 1e-6, "CDF(Q(q)) != q: q={q}, cdf={cdf}");
1590 }
1591 }
1592
1593 #[test]
1594 fn tweedie_quantile_returns_zero_atom_for_low_tail() {
1595 // When the requested lower-tail probability is at or below the point
1596 // mass at zero `e^{−λ}`, the quantile is exactly 0 (right-skewed low
1597 // means) — the zero-atom behaviour a continuous surrogate cannot mimic.
1598 let mu = 0.4_f64; // small mean ⇒ large zero atom
1599 let phi = 1.0_f64;
1600 let power = 1.5_f64;
1601 let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
1602 let zero_mass = (-lambda).exp();
1603 assert!(
1604 zero_mass > 0.025,
1605 "fixture must have a fat zero atom: {zero_mass}"
1606 );
1607 assert_eq!(tweedie_quantile(0.025, mu, phi, power), 0.0);
1608 assert_eq!(tweedie_quantile(0.5 * zero_mass, mu, phi, power), 0.0);
1609 }
1610
1611 #[test]
1612 fn tweedie_quantile_boundaries_and_degeneracy() {
1613 let (mu, phi, power) = (2.0_f64, 1.0_f64, 1.6_f64);
1614 assert_eq!(tweedie_quantile(0.0, mu, phi, power), 0.0);
1615 assert_eq!(tweedie_quantile(-0.1, mu, phi, power), 0.0);
1616 assert_eq!(tweedie_quantile(1.0, mu, phi, power), f64::INFINITY);
1617 // Power outside (1, 2) or non-positive params are NaN.
1618 assert!(tweedie_quantile(0.5, mu, phi, 2.0).is_nan());
1619 assert!(tweedie_quantile(0.5, mu, phi, 1.0).is_nan());
1620 assert!(tweedie_quantile(0.5, 0.0, phi, power).is_nan());
1621 assert!(tweedie_quantile(0.5, mu, 0.0, power).is_nan());
1622 }
1623
1624 #[test]
1625 fn tweedie_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
1626 // total_var = φμ^p ⇒ φ_eff = φ, recovering the exact conditional Tweedie
1627 // quantiles.
1628 let mu = 3.0_f64;
1629 let phi = 1.2_f64;
1630 let power = 1.5_f64;
1631 let total_var = phi * mu.powf(power);
1632 let (lo, hi) =
1633 tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
1634 assert_eq!(lo, tweedie_quantile(0.025, mu, phi, power));
1635 assert_eq!(hi, tweedie_quantile(0.975, mu, phi, power));
1636 }
1637
1638 #[test]
1639 fn tweedie_moment_matched_interval_is_skewed_not_symmetric() {
1640 // A right-skewed Tweedie has the upper edge farther from the mean than
1641 // the lower edge — the symmetric `mu ± z·σ` band cannot reproduce this.
1642 let mu = 2.0_f64;
1643 let phi = 1.5_f64;
1644 let power = 1.5_f64;
1645 let total_var = phi * mu.powf(power);
1646 let (lo, hi) =
1647 tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
1648 assert!(lo >= 0.0 && hi > mu && lo < mu);
1649 assert!(
1650 hi - mu > mu - lo,
1651 "interval is not right-skewed: lo={lo}, hi={hi}"
1652 );
1653 }
1654
1655 #[test]
1656 fn tweedie_moment_matched_interval_widens_with_estimation_uncertainty() {
1657 // Adding estimation variance raises φ_eff and must not shrink the band;
1658 // the upper edge grows.
1659 let mu = 4.0_f64;
1660 let phi = 1.0_f64;
1661 let power = 1.5_f64;
1662 let obs_var = phi * mu.powf(power);
1663 let (lo0, hi0) =
1664 tweedie_moment_matched_interval(mu, phi, power, obs_var, 0.025, 0.975).unwrap();
1665 let (lo1, hi1) =
1666 tweedie_moment_matched_interval(mu, phi, power, obs_var + 30.0, 0.025, 0.975).unwrap();
1667 assert!(
1668 lo1 <= lo0 && hi1 > hi0,
1669 "band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1670 );
1671 }
1672
1673 #[test]
1674 fn tweedie_moment_matched_interval_rejects_degenerate_inputs() {
1675 assert!(tweedie_moment_matched_interval(0.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1676 assert!(tweedie_moment_matched_interval(-1.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1677 assert!(tweedie_moment_matched_interval(2.0, 0.0, 1.5, 1.0, 0.025, 0.975).is_none());
1678 assert!(tweedie_moment_matched_interval(2.0, 1.0, 2.0, 1.0, 0.025, 0.975).is_none());
1679 assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 0.0, 0.025, 0.975).is_none());
1680 assert!(tweedie_moment_matched_interval(f64::NAN, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1681 assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 6.0, 0.025, 0.975).is_some());
1682 }
1683}