use crate::estimate::EstimationError;
use crate::mixture_link::inverse_link_jet_for_family_public;
use crate::types::LikelihoodSpec;
use ndarray::{Array1, ArrayView1};
use statrs::function::beta::{beta_reg, inv_beta_reg};
use statrs::function::erf::erfc;
#[inline]
pub fn normal_pdf(x: f64) -> f64 {
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
INV_SQRT_2PI * (-0.5 * x * x).exp()
}
#[inline]
pub fn normal_cdf(x: f64) -> f64 {
0.5 * erfc(-x / std::f64::consts::SQRT_2)
}
pub use gam_math::probability::erfcx_nonnegative;
pub use gam_math::probability::log1mexp_positive;
pub use gam_math::probability::signed_log_sum_exp;
pub use gam_math::special::binomial_coefficient_f64;
pub use gam_math::special::stable_polynomial_times_exp_neg;
pub use gam_math::probability::normal_logcdf;
pub use gam_math::probability::normal_logsf;
pub use gam_math::probability::signed_probit_logcdf_and_mills_ratio;
pub use gam_math::probability::standard_normal_quantile;
pub fn gamma_quantile(p: f64, shape: f64, scale: f64) -> f64 {
if !(shape.is_finite() && shape > 0.0 && scale.is_finite() && scale > 0.0) {
return f64::NAN;
}
scale * inverse_regularized_lower_gamma(p, shape)
}
pub fn gamma_moment_matched_interval(
mu: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
return None;
}
let shape = mu * mu / total_var;
let scale = total_var / mu;
let q_lo = gamma_quantile(p_lo, shape, scale);
let q_hi = gamma_quantile(p_hi, shape, scale);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
pub fn beta_quantile(p: f64, a: f64, b: f64) -> f64 {
if !(a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return 1.0;
}
inv_beta_reg(a, b, p)
}
pub fn beta_moment_matched_interval(
mu: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite() && mu > 0.0 && mu < 1.0 && total_var.is_finite() && total_var > 0.0) {
return None;
}
let max_var = mu * (1.0 - mu);
if total_var >= max_var {
return None;
}
let precision = max_var / total_var - 1.0; let a = mu * precision;
let b = (1.0 - mu) * precision;
let q_lo = beta_quantile(p_lo, a, b);
let q_hi = beta_quantile(p_hi, a, b);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
#[inline]
fn negative_binomial_cdf_at(k: f64, theta: f64, prob: f64) -> f64 {
beta_reg(theta, k + 1.0, prob.clamp(0.0, 1.0))
}
fn count_quantile_bracket_bisect(cdf: impl Fn(f64) -> f64, seed: f64, p: f64) -> f64 {
let mut lo: f64;
let mut hi: f64;
if cdf(seed) >= p {
hi = seed;
lo = 0.0;
let mut step = 1.0;
let mut cand = seed - 1.0;
while cand > 0.0 && cdf(cand) >= p {
hi = cand;
step *= 2.0;
cand = seed - step;
}
if cand > 0.0 {
lo = cand; }
} else {
lo = seed; let mut step = 1.0;
let mut cand = seed + 1.0;
while cdf(cand) < p {
lo = cand;
step *= 2.0;
cand = seed + step;
if cand > 1.0e18 {
return f64::INFINITY;
}
}
hi = cand;
}
while hi - lo > 1.0 {
let mid = (lo + (hi - lo) / 2.0).floor();
if cdf(mid) >= p {
hi = mid;
} else {
lo = mid;
}
}
hi
}
pub fn negative_binomial_quantile(p: f64, mu: f64, theta: f64) -> f64 {
if !(mu.is_finite() && mu >= 0.0 && theta.is_finite() && theta > 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return f64::INFINITY;
}
if mu == 0.0 {
return 0.0;
}
let prob = theta / (theta + mu); let cdf = |k: f64| negative_binomial_cdf_at(k, theta, prob);
if cdf(0.0) >= p {
return 0.0;
}
let var = mu + mu * mu / theta;
let z = standard_normal_quantile(p).unwrap_or(0.0);
let seed = (mu + z * var.sqrt()).floor().max(1.0);
count_quantile_bracket_bisect(&cdf, seed, p)
}
pub fn negative_binomial_moment_matched_interval(
mu: f64,
theta: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite()
&& mu > 0.0
&& theta.is_finite()
&& theta > 0.0
&& total_var.is_finite()
&& total_var > 0.0)
{
return None;
}
let excess = total_var - mu;
let theta_eff = if excess > 0.0 {
mu * mu / excess
} else {
theta
};
let q_lo = negative_binomial_quantile(p_lo, mu, theta_eff);
let q_hi = negative_binomial_quantile(p_hi, mu, theta_eff);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
#[inline]
fn poisson_cdf_at(k: f64, mu: f64) -> f64 {
(1.0 - regularized_lower_gamma(k + 1.0, mu)).clamp(0.0, 1.0)
}
pub fn poisson_quantile(p: f64, mu: f64) -> f64 {
if !(mu.is_finite() && mu >= 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return f64::INFINITY;
}
if mu == 0.0 {
return 0.0;
}
let cdf = |k: f64| poisson_cdf_at(k, mu);
if cdf(0.0) >= p {
return 0.0;
}
let z = standard_normal_quantile(p).unwrap_or(0.0);
let seed = (mu + z * mu.sqrt()).floor().max(1.0);
count_quantile_bracket_bisect(&cdf, seed, p)
}
pub fn poisson_moment_matched_interval(
mu: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
return None;
}
let excess = total_var - mu;
if excess < 0.0 {
return None;
}
const THETA_EFF_MAX: f64 = 1.0e9;
let theta_eff = if excess > 0.0 {
mu * mu / excess
} else {
f64::INFINITY
};
let (q_lo, q_hi) = if theta_eff > THETA_EFF_MAX {
(poisson_quantile(p_lo, mu), poisson_quantile(p_hi, mu))
} else {
(
negative_binomial_quantile(p_lo, mu, theta_eff),
negative_binomial_quantile(p_hi, mu, theta_eff),
)
};
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
#[inline]
fn tweedie_cdf_at(y: f64, mu: f64, phi: f64, power: f64) -> f64 {
if !(y.is_finite() && y >= 0.0) {
return f64::NAN;
}
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let alpha = (2.0 - power) / (power - 1.0);
let scale = phi * (power - 1.0) * mu.powf(power - 1.0);
let zero_mass = (-lambda).exp();
if y <= 0.0 {
return zero_mass;
}
let x = y / scale; let mut acc = zero_mass; let mut ln_w = -lambda; let k_max = (lambda + 10.0 * lambda.sqrt()).ceil() as usize + 50;
let mut remaining = 1.0 - zero_mass; for k in 1..=k_max {
ln_w += lambda.ln() - (k as f64).ln();
let w = ln_w.exp();
remaining -= w;
acc += w * regularized_lower_gamma(alpha * k as f64, x);
if remaining <= 1e-15 && k as f64 > lambda {
break;
}
}
acc.clamp(0.0, 1.0)
}
pub fn tweedie_quantile(q: f64, mu: f64, phi: f64, power: f64) -> f64 {
if !(mu.is_finite()
&& mu > 0.0
&& phi.is_finite()
&& phi > 0.0
&& power.is_finite()
&& power > 1.0
&& power < 2.0)
{
return f64::NAN;
}
if !q.is_finite() || q <= 0.0 {
return 0.0;
}
if q >= 1.0 {
return f64::INFINITY;
}
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let zero_mass = (-lambda).exp();
if q <= zero_mass {
return 0.0;
}
let var = phi * mu.powf(power);
let z = standard_normal_quantile(q).unwrap_or(0.0);
let mut hi = (mu + z * var.sqrt()).max(scale_floor(mu));
let cdf = |y: f64| tweedie_cdf_at(y, mu, phi, power);
let mut lo = 0.0_f64;
let mut guard = 0;
while cdf(hi) < q {
lo = hi;
hi *= 2.0;
guard += 1;
if guard > 200 || hi > 1.0e18 {
return f64::INFINITY;
}
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if cdf(mid) < q {
lo = mid;
} else {
hi = mid;
}
if hi - lo <= (hi.abs() + 1.0) * 1e-12 {
break;
}
}
0.5 * (lo + hi)
}
#[inline]
fn scale_floor(mu: f64) -> f64 {
(mu * 1e-3).max(f64::MIN_POSITIVE)
}
pub fn tweedie_moment_matched_interval(
mu: f64,
phi: f64,
power: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite()
&& mu > 0.0
&& phi.is_finite()
&& phi > 0.0
&& power.is_finite()
&& power > 1.0
&& power < 2.0
&& total_var.is_finite()
&& total_var > 0.0)
{
return None;
}
let phi_eff = total_var / mu.powf(power);
if !(phi_eff.is_finite() && phi_eff > 0.0) {
return None;
}
let q_lo = tweedie_quantile(p_lo, mu, phi_eff, power);
let q_hi = tweedie_quantile(p_hi, mu, phi_eff, power);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
fn regularized_lower_gamma(a: f64, x: f64) -> f64 {
use statrs::function::gamma::ln_gamma;
if x <= 0.0 {
return 0.0;
}
let gln = ln_gamma(a);
if x < a + 1.0 {
let mut ap = a;
let mut del = 1.0 / a;
let mut sum = del;
for _ in 0..1000 {
ap += 1.0;
del *= x / ap;
sum += del;
if del.abs() <= sum.abs() * f64::EPSILON {
break;
}
}
(sum.ln() + a * x.ln() - x - gln).exp()
} else {
const FPMIN: f64 = 1e-300;
let mut b = x + 1.0 - a;
let mut c = 1.0 / FPMIN;
let mut d = 1.0 / b;
let mut h = d;
for i in 1..1000 {
let an = -(i as f64) * (i as f64 - a);
b += 2.0;
d = an * d + b;
if d.abs() < FPMIN {
d = FPMIN;
}
c = b + an / c;
if c.abs() < FPMIN {
c = FPMIN;
}
d = 1.0 / d;
let del = d * c;
h *= del;
if (del - 1.0).abs() <= f64::EPSILON {
break;
}
}
let q = (a * x.ln() - x - gln + h.ln()).exp();
1.0 - q
}
}
fn inverse_regularized_lower_gamma(p: f64, a: f64) -> f64 {
use statrs::function::gamma::ln_gamma;
if !(a.is_finite() && a > 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return f64::INFINITY;
}
let gln = ln_gamma(a);
let a1 = a - 1.0;
let mut x = if a > 1.0 {
let pp = if p < 0.5 { p } else { 1.0 - p };
let t = (-2.0 * pp.ln()).sqrt();
let mut z = (2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t;
if p < 0.5 {
z = -z;
}
let wh_inner = 1.0 - 1.0 / (9.0 * a) - z / (3.0 * a.sqrt());
let wh_seed = if wh_inner > 0.0 {
a * wh_inner.powi(3)
} else {
f64::NAN
};
let analytic_seed = ((p.ln() + ln_gamma(a + 1.0)) / a).exp();
if analytic_seed == 0.0 {
return 0.0;
}
if !wh_seed.is_finite() || wh_seed <= 0.0 || wh_seed < 1.0e-2 || analytic_seed < 1.0e-2 {
analytic_seed
} else {
wh_seed
}
} else {
let t = 1.0 - a * (0.253 + a * 0.12);
if p < t {
(p / t).powf(1.0 / a)
} else {
1.0 - (1.0 - (p - t) / (1.0 - t)).ln()
}
};
let (lna1, afac) = if a > 1.0 {
let lna1 = a1.ln();
(lna1, (a1 * (lna1 - 1.0) - gln).exp())
} else {
(0.0, 0.0)
};
const MAX_HALLEY_STEPS: usize = 16;
for _ in 0..MAX_HALLEY_STEPS {
if x <= 0.0 {
return 0.0;
}
let err = regularized_lower_gamma(a, x) - p;
let dens = if a > 1.0 {
afac * (-(x - a1) + a1 * (x.ln() - lna1)).exp()
} else {
(-x + a1 * x.ln() - gln).exp()
};
if !(dens.is_finite() && dens > 0.0) {
break;
}
let u = err / dens;
let step = u / (1.0 - 0.5 * (u * (a1 / x - 1.0)).min(1.0));
x -= step;
if x <= 0.0 {
x = 0.5 * (x + step);
}
if step.abs() < 1.0e-12 * x.max(1.0e-300) {
break;
}
}
x
}
#[inline]
pub fn try_inverse_link_array(
likelihood: &LikelihoodSpec,
eta: ArrayView1<'_, f64>,
) -> Result<Array1<f64>, EstimationError> {
let mut out = Array1::<f64>::zeros(eta.len());
for i in 0..eta.len() {
out[i] = inverse_link_jet_for_family_public(likelihood, eta[i])?.mu;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mixture_link::{state_from_sasspec, state_fromspec};
use crate::types::{
InverseLink, LinkComponent, MixtureLinkSpec, ResponseFamily, SasLinkSpec, StandardLink,
};
use ndarray::array;
#[test]
fn signed_log_sum_exp_propagates_positive_infinities() {
let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[1.0]);
assert_eq!(lm, f64::INFINITY);
assert_eq!(s, 1.0);
let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[-1.0]);
assert_eq!(lm, f64::INFINITY);
assert_eq!(s, -1.0);
let (lm, s) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
assert!(lm.is_nan());
assert_eq!(s, 0.0);
let (lm, s) = signed_log_sum_exp(&[0.0, f64::INFINITY], &[1.0, 1.0]);
assert_eq!(lm, f64::INFINITY);
assert_eq!(s, 1.0);
let (lm, s) = signed_log_sum_exp(&[2.0, f64::NEG_INFINITY], &[1.0, -1.0]);
assert!((lm - 2.0).abs() < 1e-12);
assert_eq!(s, 1.0);
let (lm, s) = signed_log_sum_exp(&[3.0_f64.ln(), 1.0_f64.ln()], &[1.0, -1.0]);
assert!((lm - 2.0_f64.ln()).abs() < 1e-12);
assert_eq!(s, 1.0);
}
#[test]
fn standard_inverse_link_specs_evaluate() {
let eta = array![0.1, -0.2, 0.3];
let likelihood = LikelihoodSpec::new(
ResponseFamily::Binomial,
InverseLink::Standard(StandardLink::Logit),
);
let mu = try_inverse_link_array(&likelihood, eta.view()).expect("standard logit spec");
assert_eq!(mu.len(), eta.len());
assert!(mu.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
}
#[test]
fn sas_and_mixture_stateful_inverse_link_evaluates() {
let eta = array![0.1, -0.2, 0.3];
let sas_likelihood = LikelihoodSpec::new(
ResponseFamily::Binomial,
InverseLink::Sas(
state_from_sasspec(SasLinkSpec {
initial_epsilon: 0.2,
initial_log_delta: -0.1,
})
.expect("sas state"),
),
);
let sas = try_inverse_link_array(&sas_likelihood, eta.view()).expect("SAS with params");
assert_eq!(sas.len(), eta.len());
assert!(sas.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
let spec = MixtureLinkSpec {
components: vec![LinkComponent::Probit, LinkComponent::CLogLog],
initial_rho: array![0.3],
};
let state = state_fromspec(&spec).expect("mixture state");
let mix_likelihood =
LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Mixture(state));
let mix = try_inverse_link_array(&mix_likelihood, eta.view()).expect("mixture with state");
assert_eq!(mix.len(), eta.len());
assert!(mix.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
}
#[test]
fn gamma_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64); 9] = [
(0.025, 4.0, 1.089_865_4),
(0.5, 4.0, 3.672_060_4),
(0.975, 4.0, 8.767_273_4),
(0.025, 1.0, 0.025_317_8), (0.975, 1.0, 3.688_879_4),
(0.5, 0.5, 0.227_468_2),
(0.99, 0.5, 3.317_448_3),
(0.025, 50.0, 37.110_963_7),
(0.975, 50.0, 64.780_598_6),
];
for (p, a, expected) in cases {
let got = gamma_quantile(p, a, 1.0);
let rel = (got - expected).abs() / expected.max(1e-12);
assert!(
rel < 1e-4,
"gamma_quantile(p={p}, a={a}) = {got}, expected ≈ {expected} (rel err {rel})"
);
}
}
#[test]
fn gamma_quantile_handles_extreme_lower_tail_for_shape_two() {
let got = gamma_quantile(1.0e-300, 2.0, 1.0);
let expected = 1.414_213_562_373_095_1e-150;
let rel = (got - expected).abs() / expected;
assert!(
rel < 1.0e-6,
"gamma_quantile(1e-300, 2, 1) = {got}, expected {expected} (rel err {rel})"
);
}
#[test]
fn gamma_quantile_round_trips_extreme_lower_tail_for_shape_above_one() {
for &a in &[1.5_f64, 2.0, 5.0, 20.0] {
for &p in &[1.0e-300_f64, 1.0e-100, 1.0e-12, 1.0e-3, 0.5, 0.999] {
let x = gamma_quantile(p, a, 1.0);
assert!(
x.is_finite() && x >= 0.0,
"non-finite quantile a={a} p={p}: {x}"
);
let recovered = regularized_lower_gamma(a, x);
let rel = (recovered - p).abs() / p;
assert!(
rel < 1.0e-6,
"round-trip failed a={a} p={p}: q={x}, P(a,q)={recovered}, rel err {rel}"
);
}
}
}
#[test]
fn gamma_quantile_is_consistent_with_the_cdf_round_trip() {
use statrs::function::gamma::gamma_lr;
for &a in &[0.3_f64, 0.75, 1.0, 2.5, 10.0, 80.0] {
for &p in &[0.001_f64, 0.01, 0.025, 0.25, 0.5, 0.75, 0.975, 0.99, 0.999] {
let x = gamma_quantile(p, a, 1.0);
assert!(
x.is_finite() && x > 0.0,
"non-finite quantile a={a} p={p}: {x}"
);
let recovered = gamma_lr(a, x);
assert!(
(recovered - p).abs() < 1e-6,
"CDF round-trip failed a={a} p={p}: P(a, {x}) = {recovered}"
);
}
}
}
#[test]
fn regularized_lower_gamma_is_accurate_and_unclamped_below_statrs_floor() {
use statrs::function::gamma::{gamma_lr, ln_gamma};
for &a in &[0.05_f64, 0.3, 1.0, 2.5, 50.0] {
for &x in &[1e-6_f64, 0.01, 0.5, 1.0, 3.0, 25.0, 120.0] {
let ours = regularized_lower_gamma(a, x);
let theirs = gamma_lr(a, x);
assert!(
(ours - theirs).abs() < 1e-12,
"P({a},{x}): ours={ours} statrs={theirs}"
);
assert!(
(0.0..=1.0).contains(&ours),
"P({a},{x})={ours} out of [0,1]"
);
}
}
for &x in &[1e-3_f64, 0.25, 2.0, 9.0] {
assert!((regularized_lower_gamma(1.0, x) - (1.0 - (-x).exp())).abs() < 1e-13);
}
for &(a, x) in &[(0.05_f64, 1e-20_f64), (0.1, 1e-25), (0.02, 1e-40)] {
assert_eq!(
gamma_lr(a, x),
0.0,
"precondition: statrs clamps P({a},{x}) to 0"
);
let ours = regularized_lower_gamma(a, x);
let leading = (a * x.ln() - ln_gamma(a + 1.0)).exp();
assert!(ours > 0.0, "P({a},{x})={ours} clamped to 0 like statrs");
assert!(
(ours - leading).abs() < 1e-9 * leading,
"P({a},{x})={ours}, leading order {leading}"
);
}
}
#[test]
fn gamma_quantile_scale_and_monotonicity() {
let q_unit = gamma_quantile(0.9, 3.0, 1.0);
let q_scaled = gamma_quantile(0.9, 3.0, 7.5);
assert!((q_scaled - 7.5 * q_unit).abs() < 1e-9 * q_scaled.max(1.0));
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = gamma_quantile(p, 2.0, 1.0);
assert!(q > prev, "quantile not increasing at p={p}: {q} <= {prev}");
prev = q;
}
}
#[test]
fn gamma_quantile_rejects_degenerate_parameters() {
assert!(gamma_quantile(0.5, -1.0, 1.0).is_nan());
assert!(gamma_quantile(0.5, 1.0, 0.0).is_nan());
assert!(gamma_quantile(0.5, f64::NAN, 1.0).is_nan());
assert_eq!(gamma_quantile(0.0, 2.0, 1.0), 0.0);
assert_eq!(gamma_quantile(-0.1, 2.0, 1.0), 0.0);
assert!(gamma_quantile(1.0, 2.0, 1.0).is_infinite());
}
#[test]
fn gamma_moment_matched_interval_is_the_exact_conditional_gamma_when_se_vanishes() {
let phi = 0.25_f64; let mu = 7.5_f64;
let total_var = phi * mu * mu; let (lo, hi) = gamma_moment_matched_interval(mu, total_var, 0.025, 0.975)
.expect("non-degenerate moment-matched Gamma interval");
let analytic_lo = gamma_quantile(0.025, 1.0 / phi, phi * mu);
let analytic_hi = gamma_quantile(0.975, 1.0 / phi, phi * mu);
assert!(
(lo - analytic_lo).abs() < 1e-9 * analytic_lo.max(1.0)
&& (hi - analytic_hi).abs() < 1e-9 * analytic_hi.max(1.0),
"moment-matched interval [{lo}, {hi}] != conditional Gamma \
[{analytic_lo}, {analytic_hi}]"
);
}
#[test]
fn gamma_moment_matched_interval_is_right_skewed_not_symmetric() {
let phi = 0.25_f64; let mu = 10.0_f64;
let total_var = phi * mu * mu;
let z = 1.959_963_984_540_054_f64; let (lo, hi) =
gamma_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
assert!(
0.0 < lo && lo < mu && mu < hi,
"interval [{lo}, {hi}] ∌ μ={mu}"
);
let lower_gap = mu - lo;
let upper_gap = hi - mu;
assert!(
upper_gap > 1.3 * lower_gap,
"expected a right-skewed band (upper gap ≫ lower gap), got \
lower_gap={lower_gap}, upper_gap={upper_gap}"
);
let symmetric_lower = mu * (1.0 - z * phi.sqrt());
assert!(
lo > 2.0 * symmetric_lower.max(0.0) + 1.0,
"skew-correct lower edge {lo} should sit well above the symmetric \
edge {symmetric_lower}"
);
}
#[test]
fn gamma_moment_matched_interval_widens_with_estimation_uncertainty() {
let phi = 0.25_f64;
let mu = 5.0_f64;
let obs_var = phi * mu * mu;
let (lo0, hi0) = gamma_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) = gamma_moment_matched_interval(mu, obs_var + 4.0, 0.025, 0.975).unwrap();
assert!(
lo1 < lo0 && hi1 > hi0,
"estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn gamma_moment_matched_interval_rejects_degenerate_and_near_gaussian_inputs() {
assert!(gamma_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(1.0, 0.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(1.0, -1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(f64::NAN, 1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(1.0, f64::INFINITY, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(3.0, 2.0, 0.025, 0.975).is_some());
}
#[test]
fn beta_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64, f64); 8] = [
(0.025, 2.0, 2.0, 0.094_299_3),
(0.975, 2.0, 2.0, 0.905_700_7),
(0.5, 2.0, 2.0, 0.5),
(0.025, 0.8, 4.0, 0.002_339_1),
(0.975, 0.8, 4.0, 0.564_717_3),
(0.025, 5.0, 1.5, 0.408_549_1),
(0.5, 20.0, 80.0, 0.197_994_8),
(0.975, 20.0, 80.0, 0.283_367_6),
];
for (p, a, b, expected) in cases {
let got = beta_quantile(p, a, b);
let abs = (got - expected).abs();
assert!(
abs < 1e-5,
"beta_quantile(p={p}, a={a}, b={b}) = {got}, expected ≈ {expected} (abs err {abs})"
);
}
}
#[test]
fn beta_quantile_boundaries_and_degeneracy() {
assert_eq!(beta_quantile(0.0, 2.0, 3.0), 0.0);
assert_eq!(beta_quantile(-0.5, 2.0, 3.0), 0.0);
assert_eq!(beta_quantile(1.0, 2.0, 3.0), 1.0);
assert_eq!(beta_quantile(1.5, 2.0, 3.0), 1.0);
assert!(beta_quantile(0.5, -1.0, 3.0).is_nan());
assert!(beta_quantile(0.5, 2.0, 0.0).is_nan());
assert!(beta_quantile(0.5, f64::NAN, 3.0).is_nan());
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = beta_quantile(p, 3.0, 5.0);
assert!(
q > prev,
"beta quantile not increasing at p={p}: {q} <= {prev}"
);
prev = q;
}
}
#[test]
fn beta_moment_matched_interval_is_the_exact_conditional_beta_when_se_vanishes() {
let phi = 8.0_f64;
let mu = 0.2_f64;
let total_var = mu * (1.0 - mu) / (1.0 + phi); let (lo, hi) = beta_moment_matched_interval(mu, total_var, 0.025, 0.975)
.expect("non-degenerate moment-matched Beta interval");
let analytic_lo = beta_quantile(0.025, mu * phi, (1.0 - mu) * phi);
let analytic_hi = beta_quantile(0.975, mu * phi, (1.0 - mu) * phi);
assert!(
(lo - analytic_lo).abs() < 1e-9 && (hi - analytic_hi).abs() < 1e-9,
"moment-matched interval [{lo}, {hi}] != conditional Beta [{analytic_lo}, {analytic_hi}]"
);
}
#[test]
fn beta_moment_matched_interval_is_skewed_not_symmetric() {
let phi = 8.0_f64;
let mu = 0.15_f64;
let total_var = mu * (1.0 - mu) / (1.0 + phi);
let z = 1.959_963_984_540_054_f64;
let (lo, hi) =
beta_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
assert!(
0.0 < lo && lo < mu && mu < hi && hi < 1.0,
"interval [{lo},{hi}] ∌ μ={mu}"
);
let lower_gap = mu - lo;
let upper_gap = hi - mu;
assert!(
upper_gap > 1.2 * lower_gap,
"expected a right-skewed band (upper gap > lower gap): lower={lower_gap}, upper={upper_gap}"
);
let symmetric_lower = mu - z * total_var.sqrt();
assert!(
symmetric_lower < 0.0 && lo > 0.0,
"skew-correct lower edge {lo} should stay positive where the symmetric edge {symmetric_lower} goes negative"
);
}
#[test]
fn beta_moment_matched_interval_rejects_degenerate_and_over_dispersed_inputs() {
assert!(beta_moment_matched_interval(0.0, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(1.0, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(-0.1, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.3, 0.0, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(f64::NAN, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.5, 0.25, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.5, 0.30, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.4, 0.02, 0.025, 0.975).is_some());
}
#[test]
fn beta_moment_matched_interval_widens_with_estimation_uncertainty() {
let phi = 8.0_f64;
let mu = 0.3_f64;
let obs_var = mu * (1.0 - mu) / (1.0 + phi);
let (lo0, hi0) = beta_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) = beta_moment_matched_interval(mu, obs_var + 0.01, 0.025, 0.975).unwrap();
assert!(
lo1 < lo0 && hi1 > hi0,
"estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn negative_binomial_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64, f64); 8] = [
(0.025, 1.6, 1.5, 0.0), (0.5, 1.6, 1.5, 1.0),
(0.975, 1.6, 1.5, 6.0),
(0.99, 1.6, 1.5, 8.0),
(0.025, 20.0, 5.0, 5.0),
(0.975, 20.0, 5.0, 43.0),
(0.5, 20.0, 5.0, 19.0),
(0.975, 0.5, 2.0, 3.0),
];
for (p, mu, theta, expected) in cases {
let got = negative_binomial_quantile(p, mu, theta);
assert_eq!(
got, expected,
"negative_binomial_quantile(p={p}, μ={mu}, θ={theta}) = {got}, expected {expected}"
);
}
}
#[test]
fn negative_binomial_quantile_is_a_valid_cdf_inverse() {
use statrs::function::beta::beta_reg;
for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
for &theta in &[0.5_f64, 1.5, 5.0, 40.0] {
let prob = theta / (theta + mu);
for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
let k = negative_binomial_quantile(p, mu, theta);
assert!(
k.is_finite() && k >= 0.0 && k.fract() == 0.0,
"non-integer k={k}"
);
let cdf_k = beta_reg(theta, k + 1.0, prob);
assert!(
cdf_k + 1e-12 >= p,
"CDF({k}) = {cdf_k} < p = {p} (μ={mu}, θ={theta})"
);
if k >= 1.0 {
let cdf_below = beta_reg(theta, k, prob);
assert!(
cdf_below < p,
"k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu}, θ={theta})",
k - 1.0
);
}
}
}
}
}
#[test]
fn negative_binomial_quantile_boundaries_and_degeneracy() {
assert_eq!(negative_binomial_quantile(0.0, 2.0, 1.5), 0.0);
assert_eq!(negative_binomial_quantile(-0.1, 2.0, 1.5), 0.0);
assert!(negative_binomial_quantile(1.0, 2.0, 1.5).is_infinite());
assert_eq!(negative_binomial_quantile(0.5, 0.0, 1.5), 0.0); assert!(negative_binomial_quantile(0.5, -1.0, 1.5).is_nan());
assert!(negative_binomial_quantile(0.5, 2.0, 0.0).is_nan());
assert!(negative_binomial_quantile(0.5, 2.0, f64::NAN).is_nan());
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = negative_binomial_quantile(p, 4.0, 2.0);
assert!(q >= prev, "NB quantile decreased at p={p}: {q} < {prev}");
prev = q;
}
}
#[test]
fn negative_binomial_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
let mu = 1.6_f64;
let theta = 1.5_f64;
let total_var = mu + mu * mu / theta;
let (lo, hi) =
negative_binomial_moment_matched_interval(mu, theta, total_var, 0.025, 0.975).unwrap();
assert_eq!(lo, negative_binomial_quantile(0.025, mu, theta));
assert_eq!(hi, negative_binomial_quantile(0.975, mu, theta));
}
#[test]
fn negative_binomial_moment_matched_interval_widens_with_estimation_uncertainty() {
let mu = 8.0_f64;
let theta = 4.0_f64;
let obs_var = mu + mu * mu / theta;
let (lo0, hi0) =
negative_binomial_moment_matched_interval(mu, theta, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) =
negative_binomial_moment_matched_interval(mu, theta, obs_var + 40.0, 0.025, 0.975)
.unwrap();
assert!(
lo1 <= lo0 && hi1 > hi0,
"band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn negative_binomial_moment_matched_interval_rejects_degenerate_inputs() {
assert!(negative_binomial_moment_matched_interval(0.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(negative_binomial_moment_matched_interval(-1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(negative_binomial_moment_matched_interval(2.0, 0.0, 1.0, 0.025, 0.975).is_none());
assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 0.0, 0.025, 0.975).is_none());
assert!(
negative_binomial_moment_matched_interval(f64::NAN, 1.5, 1.0, 0.025, 0.975).is_none()
);
assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 6.0, 0.025, 0.975).is_some());
}
#[test]
fn poisson_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64); 9] = [
(0.025, 1.6, 0.0), (0.5, 1.6, 1.0),
(0.975, 1.6, 4.0),
(0.99, 1.6, 5.0),
(0.025, 20.0, 12.0),
(0.975, 20.0, 29.0),
(0.5, 20.0, 20.0),
(0.975, 0.5, 2.0),
(0.025, 0.5, 0.0),
];
for (p, mu, expected) in cases {
let got = poisson_quantile(p, mu);
assert_eq!(
got, expected,
"poisson_quantile(p={p}, μ={mu}) = {got}, expected {expected}"
);
}
}
#[test]
fn poisson_quantile_is_a_valid_cdf_inverse() {
for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
let k = poisson_quantile(p, mu);
assert!(
k.is_finite() && k >= 0.0 && k.fract() == 0.0,
"non-integer k={k}"
);
let cdf_k = poisson_cdf_at(k, mu);
assert!(cdf_k + 1e-12 >= p, "CDF({k}) = {cdf_k} < p = {p} (μ={mu})");
if k >= 1.0 {
let cdf_below = poisson_cdf_at(k - 1.0, mu);
assert!(
cdf_below < p,
"k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu})",
k - 1.0
);
}
}
}
}
#[test]
fn poisson_quantile_boundaries_and_degeneracy() {
assert_eq!(poisson_quantile(0.0, 2.0), 0.0);
assert_eq!(poisson_quantile(-0.1, 2.0), 0.0);
assert!(poisson_quantile(1.0, 2.0).is_infinite());
assert_eq!(poisson_quantile(0.5, 0.0), 0.0); assert!(poisson_quantile(0.5, -1.0).is_nan());
assert!(poisson_quantile(0.5, f64::NAN).is_nan());
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = poisson_quantile(p, 4.0);
assert!(
q >= prev,
"Poisson quantile decreased at p={p}: {q} < {prev}"
);
prev = q;
}
}
#[test]
fn poisson_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
for &mu in &[0.5_f64, 1.6, 20.0] {
let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
assert_eq!(lo, poisson_quantile(0.025, mu));
assert_eq!(hi, poisson_quantile(0.975, mu));
}
}
#[test]
fn poisson_moment_matched_interval_widens_with_estimation_uncertainty() {
let mu = 20.0_f64;
let (lo0, hi0) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
let (lo1, hi1) = poisson_moment_matched_interval(mu, mu + 40.0, 0.025, 0.975).unwrap();
assert!(
lo1 <= lo0 && hi1 > hi0,
"band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
let (lo2, hi2) =
poisson_moment_matched_interval(mu, mu + mu * mu * 1.0e-12, 0.025, 0.975).unwrap();
assert_eq!((lo2, hi2), (lo0, hi0));
}
#[test]
fn poisson_moment_matched_interval_is_skewed_not_symmetric() {
let mu = 2.0_f64;
let z = standard_normal_quantile(0.975).unwrap();
let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
let sym_hi = mu + z * mu.sqrt();
assert!(
hi > sym_hi,
"equal-tailed upper {hi} should exceed symmetric upper {sym_hi}"
);
assert!(
(hi - mu) > (mu - lo),
"band not right-skewed: lo={lo}, hi={hi}, μ={mu}"
);
}
#[test]
fn poisson_moment_matched_interval_rejects_degenerate_inputs() {
assert!(poisson_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(2.0, 0.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(2.0, 1.0, 0.025, 0.975).is_none()); assert!(poisson_moment_matched_interval(f64::NAN, 5.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(2.0, 5.0, 0.025, 0.975).is_some());
}
#[test]
fn tweedie_quantile_is_a_valid_cdf_inverse() {
let mu = 3.0_f64;
let phi = 1.2_f64;
let power = 1.5_f64;
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let zero_mass = (-lambda).exp();
for &q in &[0.30_f64, 0.5, 0.75, 0.9, 0.975, 0.99] {
assert!(
q > zero_mass,
"test q must exceed the zero atom {zero_mass}"
);
let y = tweedie_quantile(q, mu, phi, power);
assert!(y.is_finite() && y > 0.0, "quantile out of support: {y}");
let cdf = tweedie_cdf_at(y, mu, phi, power);
assert!((cdf - q).abs() < 1e-6, "CDF(Q(q)) != q: q={q}, cdf={cdf}");
}
}
#[test]
fn tweedie_quantile_returns_zero_atom_for_low_tail() {
let mu = 0.4_f64; let phi = 1.0_f64;
let power = 1.5_f64;
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let zero_mass = (-lambda).exp();
assert!(
zero_mass > 0.025,
"fixture must have a fat zero atom: {zero_mass}"
);
assert_eq!(tweedie_quantile(0.025, mu, phi, power), 0.0);
assert_eq!(tweedie_quantile(0.5 * zero_mass, mu, phi, power), 0.0);
}
#[test]
fn tweedie_quantile_boundaries_and_degeneracy() {
let (mu, phi, power) = (2.0_f64, 1.0_f64, 1.6_f64);
assert_eq!(tweedie_quantile(0.0, mu, phi, power), 0.0);
assert_eq!(tweedie_quantile(-0.1, mu, phi, power), 0.0);
assert_eq!(tweedie_quantile(1.0, mu, phi, power), f64::INFINITY);
assert!(tweedie_quantile(0.5, mu, phi, 2.0).is_nan());
assert!(tweedie_quantile(0.5, mu, phi, 1.0).is_nan());
assert!(tweedie_quantile(0.5, 0.0, phi, power).is_nan());
assert!(tweedie_quantile(0.5, mu, 0.0, power).is_nan());
}
#[test]
fn tweedie_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
let mu = 3.0_f64;
let phi = 1.2_f64;
let power = 1.5_f64;
let total_var = phi * mu.powf(power);
let (lo, hi) =
tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
assert_eq!(lo, tweedie_quantile(0.025, mu, phi, power));
assert_eq!(hi, tweedie_quantile(0.975, mu, phi, power));
}
#[test]
fn tweedie_moment_matched_interval_is_skewed_not_symmetric() {
let mu = 2.0_f64;
let phi = 1.5_f64;
let power = 1.5_f64;
let total_var = phi * mu.powf(power);
let (lo, hi) =
tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
assert!(lo >= 0.0 && hi > mu && lo < mu);
assert!(
hi - mu > mu - lo,
"interval is not right-skewed: lo={lo}, hi={hi}"
);
}
#[test]
fn tweedie_moment_matched_interval_widens_with_estimation_uncertainty() {
let mu = 4.0_f64;
let phi = 1.0_f64;
let power = 1.5_f64;
let obs_var = phi * mu.powf(power);
let (lo0, hi0) =
tweedie_moment_matched_interval(mu, phi, power, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) =
tweedie_moment_matched_interval(mu, phi, power, obs_var + 30.0, 0.025, 0.975).unwrap();
assert!(
lo1 <= lo0 && hi1 > hi0,
"band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn tweedie_moment_matched_interval_rejects_degenerate_inputs() {
assert!(tweedie_moment_matched_interval(0.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(-1.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 0.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 1.0, 2.0, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 0.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(f64::NAN, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 6.0, 0.025, 0.975).is_some());
}
}