Skip to main content

prospicio_prob/
gamma.rs

1//! The gamma distribution.
2
3use prospicio_core::{Error, Result, StreamRng};
4use prospicio_math::roots::bisect;
5use prospicio_math::special::{gamma_inc, ln_gamma, norm_quantile};
6
7use crate::distribution::{Distribution, check_probability};
8use crate::severity::Severity;
9
10/// Gamma distribution with shape `α` and scale `θ`: density
11/// `x^(α-1) e^(-x/θ) / (Γ(α) θ^α)`, mean `αθ`, variance `αθ²`.
12///
13/// Parameterized as in SciPy (`a = shape`, `scale`) and R (`shape`,
14/// `scale = 1/rate`). The gamma GLM family is this distribution with
15/// shape `1/φ` and mean `μ`; see [`Gamma::from_mean_dispersion`].
16///
17/// # Example
18///
19/// ```
20/// use prospicio_prob::{Distribution, Gamma, Severity};
21///
22/// let d = Gamma::from_mean_cv(1000.0, 0.5).unwrap();
23/// assert!((d.shape() - 4.0).abs() < 1e-12);
24/// assert!((d.std_dev() - 500.0).abs() < 1e-9);
25/// // LEV + stop-loss = mean.
26/// assert!((d.lev(1500.0) + d.stop_loss(1500.0) - 1000.0).abs() < 1e-9);
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct Gamma {
30    shape: f64,
31    scale: f64,
32}
33
34impl Gamma {
35    /// Gamma with shape `α > 0` and scale `θ > 0`.
36    pub fn new(shape: f64, scale: f64) -> Result<Self> {
37        positive("shape", shape)?;
38        positive("scale", scale)?;
39        Ok(Self { shape, scale })
40    }
41
42    /// Gamma with the given mean and coefficient of variation: shape
43    /// `1/cv²`, scale `mean cv²`.
44    pub fn from_mean_cv(mean: f64, cv: f64) -> Result<Self> {
45        positive("mean", mean)?;
46        positive("cv", cv)?;
47        Self::new(1.0 / (cv * cv), mean * cv * cv)
48    }
49
50    /// Gamma with mean `μ` and GLM dispersion `φ` (variance `φμ²`): shape
51    /// `1/φ`, scale `φμ`.
52    pub fn from_mean_dispersion(mean: f64, dispersion: f64) -> Result<Self> {
53        positive("mean", mean)?;
54        positive("dispersion", dispersion)?;
55        Self::new(1.0 / dispersion, dispersion * mean)
56    }
57
58    /// Shape `α`.
59    pub fn shape(&self) -> f64 {
60        self.shape
61    }
62
63    /// Scale `θ`.
64    pub fn scale(&self) -> f64 {
65        self.scale
66    }
67
68    /// Log density at `x`; `-inf` outside the support (and at 0 for
69    /// shape above 1).
70    ///
71    /// ```
72    /// use prospicio_prob::Gamma;
73    ///
74    /// // Shape 1 is the exponential: f(x) = e^(-x/θ) / θ.
75    /// let d = Gamma::new(1.0, 2.0).unwrap();
76    /// assert!((d.ln_pdf(3.0) - (-1.5 - 2f64.ln())).abs() < 1e-15);
77    /// ```
78    pub fn ln_pdf(&self, x: f64) -> f64 {
79        if x < 0.0 || x.is_nan() {
80            return f64::NEG_INFINITY;
81        }
82        let (a, t) = (self.shape, self.scale);
83        if x == 0.0 {
84            return if a < 1.0 {
85                f64::INFINITY
86            } else if a == 1.0 {
87                -t.ln()
88            } else {
89                f64::NEG_INFINITY
90            };
91        }
92        (a - 1.0) * x.ln() - x / t - ln_gamma(a) - a * t.ln()
93    }
94
95    /// `E[X^k; X > u] - u^k S(u)`, the tail part of `E[X^k] -
96    /// E[min(X, u)^k]`, for `k` in 1 and 2. Small in the tail, so layer
97    /// moments built from it keep their precision.
98    fn tail_moment(&self, k: i32, u: f64) -> f64 {
99        if u <= 0.0 {
100            return self.raw_moment(k) - u.powi(k);
101        }
102        if u == f64::INFINITY {
103            return 0.0;
104        }
105        let z = u / self.scale;
106        let (_, q_shifted) = gamma_inc(self.shape + f64::from(k), z);
107        let (_, q) = gamma_inc(self.shape, z);
108        (self.raw_moment(k) * q_shifted - u.powi(k) * q).max(0.0)
109    }
110
111    /// `E[X^k]` for `k` in 1 and 2.
112    fn raw_moment(&self, k: i32) -> f64 {
113        let (a, t) = (self.shape, self.scale);
114        match k {
115            1 => a * t,
116            _ => a * (a + 1.0) * t * t,
117        }
118    }
119}
120
121impl Distribution for Gamma {
122    fn mean(&self) -> f64 {
123        self.shape * self.scale
124    }
125
126    fn variance(&self) -> f64 {
127        self.shape * self.scale * self.scale
128    }
129
130    fn cdf(&self, x: f64) -> f64 {
131        if x <= 0.0 {
132            return 0.0;
133        }
134        gamma_inc(self.shape, x / self.scale).0
135    }
136
137    fn survival(&self, x: f64) -> f64 {
138        if x <= 0.0 {
139            return 1.0;
140        }
141        gamma_inc(self.shape, x / self.scale).1
142    }
143
144    /// By bisection to full precision, on the distribution function below
145    /// the median and on the survival function above it, so both tails
146    /// keep their relative precision.
147    fn quantile(&self, p: f64) -> Result<f64> {
148        check_probability(p)?;
149        if p == 0.0 {
150            return Ok(0.0);
151        }
152        if p == 1.0 {
153            return Ok(f64::INFINITY);
154        }
155        let below = |x: f64| {
156            if p <= 0.5 {
157                self.cdf(x) < p
158            } else {
159                self.survival(x) > 1.0 - p
160            }
161        };
162        let mut hi = self.mean() + self.std_dev();
163        while below(hi) {
164            hi *= 2.0;
165        }
166        Ok(bisect(0.0, hi, below))
167    }
168
169    /// `n` draws by Marsaglia and Tsang (2000), not inverse transform:
170    /// each is `θ` times a Gamma(`α`, 1) draw from `rng` (for `α < 1`, a
171    /// draw at `α + 1` times `U^(1/α)`), so draws stay a pure function of
172    /// `(seed, stream)` but are not monotone in one uniform. The quantile
173    /// function costs a bisection on the incomplete gamma function, whose
174    /// series grows with the shape, where this costs about one normal and
175    /// one uniform at any shape.
176    fn sample(&self, rng: &mut StreamRng, n: usize) -> Vec<f64> {
177        (0..n)
178            .map(|_| self.scale * standard_gamma(rng, self.shape))
179            .collect()
180    }
181}
182
183/// A Gamma(`shape`, 1) draw by Marsaglia and Tsang (2000), "A simple
184/// method for generating gamma variables", ACM Transactions on
185/// Mathematical Software 26(3), for `shape >= 1`; below 1, their boost: a
186/// draw at `shape + 1` times `U^(1/shape)`.
187///
188/// Every uniform comes from `rng` in order: for `shape >= 1`, each attempt
189/// takes a normal (by inverse transform) and, unless `1 + c x <= 0`, a
190/// uniform; above shape 1 at least 95% of attempts are accepted. The boost
191/// takes its uniform after the draw at `shape + 1`. A draw below
192/// `f64::MIN_POSITIVE` comes out subnormal, with fewer significant bits,
193/// and one below the smallest subnormal (about `5e-324`) rounds to 0: at
194/// shape `1e-3`, 49% and about 47.5% of the mass.
195pub(crate) fn standard_gamma(rng: &mut StreamRng, shape: f64) -> f64 {
196    if shape < 1.0 {
197        let g = standard_gamma(rng, shape + 1.0);
198        return g * rng.next_open01().powf(1.0 / shape);
199    }
200    let d = shape - 1.0 / 3.0;
201    let c = 1.0 / (9.0 * d).sqrt();
202    loop {
203        let x = norm_quantile(rng.next_open01());
204        let v = 1.0 + c * x;
205        if v <= 0.0 {
206            continue;
207        }
208        let v = v * v * v;
209        let u = rng.next_open01();
210        // The squeeze accepts most draws without the logarithms.
211        let x2 = x * x;
212        if u < 1.0 - 0.0331 * x2 * x2 || u.ln() < 0.5 * x2 + d - d * v + d * v.ln() {
213            return d * v;
214        }
215    }
216}
217
218impl Severity for Gamma {
219    /// `E[min(X, u)] = αθ P(α + 1, u/θ) + u Q(α, u/θ)`.
220    fn lev(&self, limit: f64) -> f64 {
221        if limit <= 0.0 {
222            return limit;
223        }
224        if limit == f64::INFINITY {
225            return self.mean();
226        }
227        let z = limit / self.scale;
228        self.mean() * gamma_inc(self.shape + 1.0, z).0 + limit * gamma_inc(self.shape, z).1
229    }
230
231    /// `E[(X - d)+] = αθ Q(α + 1, d/θ) - d Q(α, d/θ)`, from the tail, so
232    /// it does not cancel against the mean.
233    fn stop_loss(&self, retention: f64) -> f64 {
234        if retention <= 0.0 {
235            return self.mean() - retention;
236        }
237        self.tail_moment(1, retention)
238    }
239
240    /// `LEV(a + limit) - LEV(a)` for a layer attaching below the mean,
241    /// where both are small, and `stop_loss(a) - stop_loss(a + limit)`
242    /// above it, where those are: neither difference cancels.
243    fn layer(&self, limit: f64, attachment: f64) -> f64 {
244        let a = attachment.max(0.0);
245        if a <= self.mean() {
246            self.lev(a + limit) - self.lev(a)
247        } else {
248            self.stop_loss(a) - self.stop_loss(a + limit)
249        }
250    }
251
252    /// With `b = a + limit` and `t_k(u) = E[X^k; X > u] - u^k S(u)`,
253    /// `E[Y²] = t_2(a) - t_2(b) - 2a (t_1(a) - t_1(b))`, every term taken
254    /// from the tail.
255    fn layer_second_moment(&self, limit: f64, attachment: f64) -> f64 {
256        let a = attachment.max(0.0);
257        let b = a + limit;
258        let t = |k: i32, u: f64| self.tail_moment(k, u);
259        (t(2, a) - t(2, b) - 2.0 * a * (t(1, a) - t(1, b))).max(0.0)
260    }
261}
262
263fn positive(name: &'static str, value: f64) -> Result<()> {
264    if value.is_finite() && value > 0.0 {
265        Ok(())
266    } else {
267        Err(Error::InvalidParameter {
268            name,
269            value,
270            reason: "must be finite and positive",
271        })
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use prospicio_core::StreamRng;
279
280    #[test]
281    fn severity_identities_and_edges() {
282        let d = Gamma::new(2.5, 400.0).unwrap();
283        for limit in [100.0, 1_000.0, 5_000.0] {
284            let sum = d.lev(limit) + d.stop_loss(limit);
285            assert!((sum - d.mean()).abs() < 1e-12 * d.mean());
286        }
287        assert_eq!(d.lev(0.0), 0.0);
288        assert_eq!(d.lev(f64::INFINITY), d.mean());
289        assert_eq!(d.stop_loss(f64::INFINITY), 0.0);
290        assert_eq!(d.stop_loss(0.0), d.mean());
291        assert!((d.layer(f64::INFINITY, 0.0) - d.mean()).abs() < 1e-9);
292        let stacked = d.layer(1_000.0, 0.0) + d.layer(1_000.0, 1_000.0);
293        assert!((stacked - d.layer(2_000.0, 0.0)).abs() < 1e-9);
294        // The whole distribution as one layer: E[X²].
295        let m2 = d.layer_second_moment(f64::INFINITY, 0.0);
296        assert!((m2 - (d.variance() + d.mean() * d.mean())).abs() < 1e-9 * m2);
297    }
298
299    #[test]
300    fn layer_variance_matches_simulation() {
301        let d = Gamma::new(1.5, 1000.0).unwrap();
302        let x = d.sample(&mut StreamRng::new(4, 0), 100_000);
303        let (l, a) = (2000.0, 1000.0);
304        let y: Vec<f64> = x.iter().map(|v| (v - a).clamp(0.0, l)).collect();
305        let n = y.len() as f64;
306        let m = y.iter().sum::<f64>() / n;
307        let v = y.iter().map(|v| (v - m).powi(2)).sum::<f64>() / n;
308        assert!((m / d.layer(l, a) - 1.0).abs() < 0.02, "{m}");
309        assert!((v / d.layer_variance(l, a) - 1.0).abs() < 0.04, "{v}");
310    }
311
312    #[test]
313    fn quantile_inverts_both_tails() {
314        let d = Gamma::new(0.3, 2.0).unwrap();
315        for p in [1e-12, 0.01, 0.5, 0.99] {
316            let x = d.quantile(p).unwrap();
317            assert!((d.cdf(x) / p - 1.0).abs() < 1e-12, "{p}");
318        }
319        let x = d.quantile(1.0 - 1e-12).unwrap();
320        assert!((d.survival(x) / 1e-12 - 1.0).abs() < 1e-3, "{x}");
321        assert_eq!(d.quantile(0.0), Ok(0.0));
322        assert_eq!(d.quantile(1.0), Ok(f64::INFINITY));
323    }
324
325    /// Central moments `mu_2` to `mu_6` of Gamma(`a`, 1) (index `k` holds
326    /// `mu_k`), from its cumulants `kappa_n = a (n - 1)!`.
327    fn central_moments(a: f64) -> [f64; 7] {
328        let mut mu = [0.0; 7];
329        mu[2] = a;
330        mu[3] = 2.0 * a;
331        mu[4] = 3.0 * a * a + 6.0 * a;
332        mu[5] = 20.0 * a * a + 24.0 * a;
333        mu[6] = 15.0 * a.powi(3) + 130.0 * a * a + 120.0 * a;
334        mu
335    }
336
337    /// The upper `z`-sigma point of a chi-square with `df` degrees of
338    /// freedom, by Wilson and Hilferty (1931).
339    fn chi_square_upper(df: f64, z: f64) -> f64 {
340        let h = 2.0 / (9.0 * df);
341        df * (1.0 - h + z * h.sqrt()).powi(3)
342    }
343
344    /// Mean, second and third central moments (about the true mean, so each
345    /// is an i.i.d. average with an exact standard error) as z-scores, and
346    /// the chi-square of the draws over 100 equiprobable bins cut at the
347    /// distribution's own quantiles, against its `1e-6` upper point, at
348    /// shapes from `1e-3` to `1e6`. Below `f64::MIN_POSITIVE` the quantiles
349    /// (and the draws, subnormal or, below about `5e-324`, 0) cannot be
350    /// told apart, so those bins merge into one: at shape `1e-3` that is
351    /// the lower 49% of the mass.
352    #[test]
353    fn sampler_matches_moments_and_quantiles() {
354        let n = 100_000;
355        let mut failures = Vec::new();
356        let shapes = [
357            1e-3, 0.01, 0.1, 0.5, 0.999, 1.0, 1.5, 3.0, 10.0, 100.0, 1e4, 1e6,
358        ];
359        for (stream, &a) in shapes.iter().enumerate() {
360            let d = Gamma::new(a, 1.0).unwrap();
361            let mut x = d.sample(&mut StreamRng::new(2026, stream as u64), n);
362            let nf = n as f64;
363            let mu = central_moments(a);
364            let average = |f: &dyn Fn(f64) -> f64| x.iter().map(|&v| f(v)).sum::<f64>() / nf;
365            let z = [
366                (average(&|v| v) - a) / (mu[2] / nf).sqrt(),
367                (average(&|v| (v - a).powi(2)) - mu[2]) / ((mu[4] - mu[2] * mu[2]) / nf).sqrt(),
368                (average(&|v| (v - a).powi(3)) - mu[3]) / ((mu[6] - mu[3] * mu[3]) / nf).sqrt(),
369            ];
370            for (name, z) in ["mean", "variance", "third moment"].iter().zip(z) {
371                if z.abs() > 5.0 {
372                    failures.push(format!("shape {a}: {name} z = {z:.2}"));
373                }
374            }
375
376            x.sort_by(f64::total_cmp);
377            let mut edges: Vec<f64> = (1..100)
378                .map(|k| d.quantile(f64::from(k) / 100.0).unwrap())
379                .filter(|&q| q >= f64::MIN_POSITIVE)
380                .collect();
381            edges.dedup();
382            let mut chi2 = 0.0;
383            let (mut below, mut cdf_below) = (0, 0.0);
384            for i in 0..=edges.len() {
385                let (count, cdf) = match edges.get(i) {
386                    Some(&e) => (x.partition_point(|&v| v <= e), d.cdf(e)),
387                    None => (n, 1.0),
388                };
389                let expected = (cdf - cdf_below) * nf;
390                chi2 += ((count - below) as f64 - expected).powi(2) / expected;
391                (below, cdf_below) = (count, cdf);
392            }
393            let critical = chi_square_upper(edges.len() as f64, 4.75);
394            if chi2 > critical {
395                failures.push(format!(
396                    "shape {a}: chi-square {chi2:.1} over {} bins, above {critical:.1}",
397                    edges.len() + 1
398                ));
399            }
400        }
401        assert!(failures.is_empty(), "{}", failures.join("\n"));
402    }
403
404    /// A regression pin of the sampler's output (Marsaglia–Tsang since
405    /// 2026-10-08), so a change to the draws is deliberate: one shape below
406    /// 1 (the boost) and one above, from one stream.
407    /// `validation/scripts/gamma_sampler.py` reproduces them independently
408    /// (ChaCha20 from `cryptography`, SciPy's `ndtri`) to within 2e-15.
409    #[test]
410    fn sample_is_pinned() {
411        let small = Gamma::new(0.3, 2.0).unwrap();
412        let large = Gamma::new(2.5, 400.0).unwrap();
413        let mut rng = StreamRng::new(42, 3);
414        let draws = [small.sample(&mut rng, 2), large.sample(&mut rng, 2)].concat();
415        assert_eq!(
416            draws,
417            [
418                0.23214754851650782,
419                0.7755693217108093,
420                110.9058975015963,
421                560.609408990688
422            ]
423        );
424    }
425
426    #[test]
427    fn exponential_and_parameterizations() {
428        let d = Gamma::new(1.0, 3.0).unwrap();
429        assert!((d.survival(6.0) - (-2f64).exp()).abs() < 1e-16);
430        let g = Gamma::from_mean_dispersion(200.0, 0.25).unwrap();
431        assert!((g.mean() - 200.0).abs() < 1e-12);
432        assert!((g.variance() - 0.25 * 200.0 * 200.0).abs() < 1e-8);
433        assert!(Gamma::new(0.0, 1.0).is_err());
434        assert!(Gamma::from_mean_cv(1.0, f64::NAN).is_err());
435    }
436}