use prospicio_core::{Error, Result};
use prospicio_math::roots::bisect;
use prospicio_math::special::{ln_gamma, norm_cdf, norm_quantile};
use prospicio_prob::{Binomial, Counting, Distribution, Gamma, NegativeBinomial, Poisson, Tweedie};
use crate::link::Link;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Family {
Gaussian,
Poisson,
Gamma,
InverseGaussian,
Binomial,
NegativeBinomial { theta: f64 },
Tweedie { power: f64 },
}
impl Family {
pub fn name(&self) -> &'static str {
match self {
Self::Gaussian => "gaussian",
Self::Poisson => "poisson",
Self::Gamma => "gamma",
Self::InverseGaussian => "inverse_gaussian",
Self::Binomial => "binomial",
Self::NegativeBinomial { .. } => "negative_binomial",
Self::Tweedie { .. } => "tweedie",
}
}
pub fn validate(&self) -> Result<()> {
match *self {
Self::NegativeBinomial { theta } if !(theta.is_finite() && theta > 0.0) => {
Err(invalid("theta", theta, "must be finite and positive"))
}
Self::Tweedie { power } if !(power > 1.0 && power < 2.0) => {
Err(invalid("power", power, "must be in (1, 2)"))
}
_ => Ok(()),
}
}
pub fn canonical_link(&self) -> Link {
match *self {
Self::Gaussian => Link::Identity,
Self::Poisson | Self::NegativeBinomial { .. } => Link::Log,
Self::Gamma => Link::Inverse,
Self::InverseGaussian => Link::InverseSquared,
Self::Binomial => Link::Logit,
Self::Tweedie { power } => Link::Power(1.0 - power),
}
}
pub fn unit_dispersion(&self) -> bool {
matches!(
self,
Self::Poisson | Self::Binomial | Self::NegativeBinomial { .. }
)
}
pub fn variance(&self, mu: f64) -> f64 {
match *self {
Self::Gaussian => 1.0,
Self::Poisson => mu,
Self::Gamma => mu * mu,
Self::InverseGaussian => mu * mu * mu,
Self::Binomial => mu * (1.0 - mu),
Self::NegativeBinomial { theta } => mu + mu * mu / theta,
Self::Tweedie { power } => mu.powf(power),
}
}
pub fn variance_deriv(&self, mu: f64) -> f64 {
match *self {
Self::Gaussian => 0.0,
Self::Poisson => 1.0,
Self::Gamma => 2.0 * mu,
Self::InverseGaussian => 3.0 * mu * mu,
Self::Binomial => 1.0 - 2.0 * mu,
Self::NegativeBinomial { theta } => 1.0 + 2.0 * mu / theta,
Self::Tweedie { power } => power * mu.powf(power - 1.0),
}
}
pub fn valid_y(&self, y: f64) -> bool {
y.is_finite()
&& match self {
Self::Gaussian => true,
Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => y >= 0.0,
Self::Gamma | Self::InverseGaussian => y > 0.0,
Self::Binomial => (0.0..=1.0).contains(&y),
}
}
pub fn valid_mu(&self, mu: f64) -> bool {
mu.is_finite()
&& match self {
Self::Gaussian => true,
Self::Binomial => mu > 0.0 && mu < 1.0,
_ => mu > 0.0,
}
}
pub fn initial_mu(&self, y: f64, weight: f64, y_mean: f64) -> f64 {
match self {
Self::Binomial => (weight * y + 0.5) / (weight + 1.0),
_ => 0.5 * (y + y_mean),
}
}
pub fn unit_deviance(&self, y: f64, mu: f64) -> f64 {
if matches!(self, Self::Poisson) && y < 0.0 {
return 2.0 * (y * (-y / mu).ln() - (y - mu));
}
let ylog = |y: f64, m: f64| if y == 0.0 { 0.0 } else { y * (y / m).ln() };
let d = match *self {
Self::Gaussian => (y - mu) * (y - mu),
Self::Poisson => 2.0 * (ylog(y, mu) - (y - mu)),
Self::Gamma => 2.0 * ((y - mu) / mu - (y / mu).ln()),
Self::InverseGaussian => (y - mu) * (y - mu) / (mu * mu * y),
Self::Binomial => 2.0 * (ylog(y, mu) + ylog(1.0 - y, 1.0 - mu)),
Self::NegativeBinomial { theta } => {
2.0 * (ylog(y, mu) - (y + theta) * ((y + theta) / (mu + theta)).ln())
}
Self::Tweedie { power: p } => {
let first = if y == 0.0 {
0.0
} else {
y.powf(2.0 - p) / ((1.0 - p) * (2.0 - p))
};
2.0 * (first - y * mu.powf(1.0 - p) / (1.0 - p) + mu.powf(2.0 - p) / (2.0 - p))
}
};
d.max(0.0)
}
pub fn log_likelihood(&self, y: f64, mu: f64, weight: f64, dispersion: f64) -> f64 {
let (w, phi) = (weight, dispersion);
match *self {
Self::Gaussian => {
-0.5 * (w * (y - mu) * (y - mu) / phi + (2.0 * std::f64::consts::PI * phi / w).ln())
}
Self::Poisson if y < 0.0 => f64::NAN,
Self::Poisson => w / phi * (y * mu.ln() - mu - ln_gamma(y + 1.0)),
Self::Gamma => {
let s = w / phi;
let r = y / mu;
s * (s * r).ln() - s * r - y.ln() - ln_gamma(s)
}
Self::InverseGaussian => {
-0.5 * (w * (y - mu) * (y - mu) / (phi * y * mu * mu)
+ (phi * y * y * y / w).ln()
+ (2.0 * std::f64::consts::PI).ln())
}
Self::Binomial => {
let k = w * y;
let ln_choose = ln_gamma(w + 1.0) - ln_gamma(k + 1.0) - ln_gamma(w - k + 1.0);
let term = |x: f64, p: f64| if x == 0.0 { 0.0 } else { x * p.ln() };
ln_choose + term(k, mu) + term(w - k, 1.0 - mu)
}
Self::NegativeBinomial { theta } => {
let ll = ln_gamma(y + theta) - ln_gamma(theta) - ln_gamma(y + 1.0)
+ theta * (theta / (theta + mu)).ln()
+ if y == 0.0 {
0.0
} else {
y * (mu / (theta + mu)).ln()
};
w / phi * ll
}
Self::Tweedie { power } => match Tweedie::new(mu, phi / w, power) {
Ok(t) => t.ln_pdf(y),
Err(_) => f64::NAN,
},
}
}
pub fn draw(&self, mu: f64, dispersion: f64, weight: f64, u: f64) -> Result<f64> {
let (w, phi) = (weight, dispersion);
match *self {
Self::Gaussian => Ok(mu + (phi / w).sqrt() * norm_quantile(u)),
Self::Poisson => {
let n = Poisson::new(mu * w / phi)?.quantile(u)?;
Ok(phi / w * n as f64)
}
Self::Binomial => {
let trials = w.round().max(1.0);
let n = Binomial::new(trials as u64, mu)?.quantile(u)?;
Ok(n as f64 / trials)
}
Self::NegativeBinomial { theta } => {
Ok(NegativeBinomial::new(theta, mu / theta)?.quantile(u)? as f64)
}
Self::Gamma => Gamma::new(w / phi, mu * phi / w)?.quantile(u),
Self::Tweedie { power } => Tweedie::new(mu, phi / w, power)?.quantile(u),
Self::InverseGaussian => {
let lambda = w / phi;
let cdf = |x: f64| inverse_gaussian_cdf(x, mu, lambda);
let mut hi = mu * 2.0;
while cdf(hi) < u {
hi *= 2.0;
}
Ok(bisect(0.0, hi, |x| cdf(x) < u))
}
}
}
}
impl Family {
pub fn cdf_bounds(&self, y: f64, mu: f64, dispersion: f64, weight: f64) -> Result<(f64, f64)> {
let (w, phi) = (weight, dispersion);
fn lattice(c: &dyn Counting, k: f64) -> (f64, f64) {
if k < 0.0 {
return (0.0, 0.0);
}
let (lo, hi) = (k.ceil(), k.floor());
let below = if lo >= 1.0 { c.cdf(lo as u64 - 1) } else { 0.0 };
(below, c.cdf(hi as u64))
}
let continuous = |f: f64| (f, f);
Ok(match *self {
Self::Gaussian => continuous(norm_cdf((y - mu) / (phi / w).sqrt())),
Self::Poisson => lattice(&Poisson::new(mu * w / phi)?, snap(y * w / phi)),
Self::Binomial => {
let trials = w.round().max(1.0);
lattice(&Binomial::new(trials as u64, mu)?, snap(y * trials))
}
Self::NegativeBinomial { theta } => {
lattice(&NegativeBinomial::new(theta, mu / theta)?, snap(y))
}
Self::Gamma => continuous(Gamma::new(w / phi, mu * phi / w)?.cdf(y)),
Self::InverseGaussian => continuous(inverse_gaussian_cdf(y, mu, w / phi)),
Self::Tweedie { power } => {
let t = Tweedie::new(mu, phi / w, power)?;
if y < 0.0 {
(0.0, 0.0)
} else if y == 0.0 {
(0.0, t.cdf(0.0))
} else {
continuous(t.cdf(y))
}
}
})
}
pub fn upper_tail_bounds(
&self,
y: f64,
mu: f64,
dispersion: f64,
weight: f64,
) -> Result<(f64, f64)> {
let (w, phi) = (weight, dispersion);
fn above(c: &dyn Counting, k: u64) -> f64 {
let mean = c.mean();
let mut total = 0.0;
let mut j = k + 1;
loop {
let term = c.pmf(j);
total += term;
if !term.is_finite() || (j as f64 > mean && term <= total * 1e-17) {
return total.min(1.0);
}
j += 1;
}
}
fn lattice(c: &dyn Counting, k: f64) -> (f64, f64) {
if k < 0.0 {
return (1.0, 1.0);
}
let (lo, hi) = (k.ceil(), k.floor());
let at = if lo >= 1.0 {
above(c, lo as u64 - 1)
} else {
1.0
};
(at, above(c, hi as u64))
}
Ok(match *self {
Self::Gaussian => {
let s = norm_cdf(-(y - mu) / (phi / w).sqrt());
(s, s)
}
Self::Poisson => lattice(&Poisson::new(mu * w / phi)?, snap(y * w / phi)),
Self::Binomial => {
let trials = w.round().max(1.0);
lattice(&Binomial::new(trials as u64, mu)?, snap(y * trials))
}
Self::NegativeBinomial { theta } => {
lattice(&NegativeBinomial::new(theta, mu / theta)?, snap(y))
}
_ => {
let (lo, hi) = self.cdf_bounds(y, mu, dispersion, weight)?;
(1.0 - lo, 1.0 - hi)
}
})
}
pub fn log_density(&self, y: f64, mu: f64, dispersion: f64, weight: f64) -> Result<f64> {
let (w, phi) = (weight, dispersion);
let ln_pmf = |c: &dyn Counting, k: f64| -> f64 {
if k < 0.0 || k.fract() != 0.0 {
f64::NEG_INFINITY
} else {
c.pmf(k as u64).ln()
}
};
Ok(match *self {
Self::Poisson if phi != 1.0 => {
let lambda = mu * w / phi;
Poisson::new(lambda)?;
if y < 0.0 {
f64::NEG_INFINITY
} else {
let n = y * w / phi;
n * lambda.ln()
- lambda
- ln_gamma(n + 1.0)
- continuous_poisson_mass(lambda).ln()
+ (w / phi).ln()
}
}
Self::Poisson => ln_pmf(&Poisson::new(mu * w / phi)?, snap(y * w / phi)),
Self::Binomial => {
let trials = w.round().max(1.0);
ln_pmf(&Binomial::new(trials as u64, mu)?, snap(y * trials))
}
Self::NegativeBinomial { theta } => {
ln_pmf(&NegativeBinomial::new(theta, mu / theta)?, snap(y))
}
_ => self.log_likelihood(y, mu, w, phi),
})
}
}
pub(crate) fn continuous_poisson_mass(lambda: f64) -> f64 {
if lambda >= 40.0 {
return 1.0;
}
let sd = lambda.sqrt();
let (lo, hi) = (
(lambda - 40.0 * sd - 40.0).max(0.0),
lambda + 40.0 * sd + 40.0,
);
let width = (0.25 * sd).max(0.5);
let panels = ((hi - lo) / width).ceil().max(1.0) as usize;
let h = (hi - lo) / panels as f64;
let ln_lambda = lambda.ln();
let g = |x: f64| Ok::<_, ()>((x * ln_lambda - lambda - ln_gamma(x + 1.0)).exp());
(0..panels)
.map(|k| {
let a = lo + k as f64 * h;
prospicio_math::integrate::gauss_legendre(g, a, a + h).unwrap_or(0.0)
})
.sum()
}
fn snap(x: f64) -> f64 {
let r = x.round();
if (x - r).abs() <= 1e-9 * r.abs().max(1.0) {
r
} else {
x
}
}
fn inverse_gaussian_cdf(x: f64, mu: f64, lambda: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
let r = (lambda / x).sqrt();
let a = norm_cdf(r * (x / mu - 1.0));
let b = norm_cdf(-r * (x / mu + 1.0));
let second = if b > 0.0 {
(2.0 * lambda / mu + b.ln()).exp()
} else {
0.0
};
(a + second).min(1.0)
}
fn invalid(name: &'static str, value: f64, reason: &'static str) -> Error {
Error::InvalidParameter {
name,
value,
reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_over_dispersed_poisson_density_integrates_to_one() {
for (mu, phi, w) in [
(0.3, 2.0, 1.0),
(2.5, 3.0, 1.0),
(30.0, 4.0, 2.0),
(400.0, 7.0, 1.0),
] {
let f = |y: f64| Family::Poisson.log_density(y, mu, phi, w).unwrap().exp();
let hi = mu * 3.0 + 60.0 * phi;
let panels = 4000;
let h = hi / panels as f64;
let total: f64 = (0..panels)
.map(|k| {
prospicio_math::integrate::gauss_legendre(
|y| Ok::<_, ()>(f(y)),
k as f64 * h,
(k + 1) as f64 * h,
)
.unwrap()
})
.sum();
assert!((total - 1.0).abs() < 1e-9, "{mu} {phi} {w}: {total}");
}
}
#[test]
fn the_over_dispersed_poisson_density_matches_the_lattice() {
let (mu, phi, w) = (300.0, 5.0, 1.0);
for k in [40u64, 60, 75] {
let y = k as f64 * phi / w;
let got = Family::Poisson.log_density(y, mu, phi, w).unwrap();
let pmf = Poisson::new(mu * w / phi).unwrap().pmf(k).ln();
assert!((got - (pmf + (w / phi).ln())).abs() < 1e-10, "{k}");
}
assert!(
Family::Poisson
.log_density(301.7, mu, phi, w)
.unwrap()
.is_finite()
);
assert_eq!(
Family::Poisson.log_density(-3.0, mu, phi, w).unwrap(),
f64::NEG_INFINITY
);
assert_eq!(
Family::Poisson.log_density(1.5, 2.0, 1.0, 1.0).unwrap(),
f64::NEG_INFINITY
);
assert!((continuous_poisson_mass(1.0) - 0.833_811_448_088_410_6).abs() < 1e-12);
}
use prospicio_core::StreamRng;
const FAMILIES: [Family; 7] = [
Family::Gaussian,
Family::Poisson,
Family::Gamma,
Family::InverseGaussian,
Family::Binomial,
Family::NegativeBinomial { theta: 2.5 },
Family::Tweedie { power: 1.5 },
];
fn sample_point(f: Family) -> (f64, f64) {
match f {
Family::Binomial => (0.3, 0.4),
_ => (2.0, 1.4),
}
}
#[test]
fn deviance_is_twice_the_log_likelihood_gap() {
for f in FAMILIES {
let (y, mu) = sample_point(f);
let (w, phi) = (if f == Family::Binomial { 1.0 } else { 3.0 }, 0.7);
let phi = if f.unit_dispersion() { 1.0 } else { phi };
if matches!(f, Family::Tweedie { .. }) {
continue;
}
let gap = f.log_likelihood(y, y, w, phi) - f.log_likelihood(y, mu, w, phi);
let want = 2.0 * phi * gap / w;
assert!((f.unit_deviance(y, mu) - want).abs() < 1e-12, "{f:?}");
}
}
#[test]
fn variance_derivative_matches_a_difference() {
for f in FAMILIES {
let (_, mu) = sample_point(f);
let h = 1e-6;
let numeric = (f.variance(mu + h) - f.variance(mu - h)) / (2.0 * h);
assert!((f.variance_deriv(mu) - numeric).abs() < 1e-8, "{f:?}");
}
}
#[test]
fn deviance_slope_is_the_score() {
for f in FAMILIES {
let (y, mu) = sample_point(f);
let h = 1e-6;
let slope = (f.unit_deviance(y, mu + h) - f.unit_deviance(y, mu - h)) / (2.0 * h);
let want = -2.0 * (y - mu) / f.variance(mu);
assert!((slope - want).abs() < 1e-6, "{f:?} {slope} {want}");
}
}
#[test]
fn draws_have_the_family_mean_and_variance() {
for f in FAMILIES {
let (_, mu) = sample_point(f);
let (w, phi) = (2.0, if f.unit_dispersion() { 1.0 } else { 0.5 });
let mut rng = StreamRng::new(7, 0);
let n = 10_000;
let x: Vec<f64> = (0..n)
.map(|_| f.draw(mu, phi, w, rng.next_open01()).unwrap())
.collect();
let m = x.iter().sum::<f64>() / n as f64;
let v = x.iter().map(|a| (a - m) * (a - m)).sum::<f64>() / n as f64;
let want_v = match f {
Family::NegativeBinomial { .. } => f.variance(mu),
_ => phi * f.variance(mu) / w,
};
let se = (want_v / n as f64).sqrt();
assert!((m - mu).abs() < 5.0 * se, "{f:?} mean {m}");
assert!(
(v / want_v - 1.0).abs() < 0.1,
"{f:?} var {v} want {want_v}"
);
}
}
#[test]
fn inverse_gaussian_cdf_is_a_distribution() {
let (mu, lambda) = (2.0, 3.0);
assert!(inverse_gaussian_cdf(1e-9, mu, lambda) < 1e-12);
assert!((inverse_gaussian_cdf(1e4, mu, lambda) - 1.0).abs() < 1e-12);
let c = inverse_gaussian_cdf(1.0, 1.0, 1e4);
assert!((0.0..=1.0).contains(&c) && (c - 0.5).abs() < 0.01, "{c}");
}
}