Skip to main content

prospicio_prob/
local_pareto.rs

1//! The log-affine local Pareto distribution: a local Pareto alpha that
2//! rises linearly in the log of the amount (see `docs/design/pareto.md`).
3
4use std::f64::consts::{LN_2, PI};
5
6use prospicio_core::Result;
7use prospicio_math::integrate::gauss_legendre;
8use prospicio_math::special::{norm_cdf, norm_pdf};
9
10use crate::distribution::{Distribution, check_probability};
11use crate::pareto::{invalid, power_integral, raw_integral};
12use crate::piecewise_pareto::PiecewisePareto;
13use crate::severity::Severity;
14
15/// Log-affine local Pareto: above the threshold `t` the local Pareto alpha
16/// is `α(x) = α₀ (1 + γ ln(x/t))`, so with `L = ln(x/t)`
17///
18/// ```text
19/// P(X > x) = exp(−α₀ L − ½ α₀ γ L²),   x ≥ t,
20/// ```
21///
22/// and 1 below `t`. The tail thins out with size (lognormal-like) at a
23/// rate set by two readable numbers: the alpha `α₀` at the threshold and
24/// `δ = α₀ γ ln 2`, the rise in alpha each time the amount doubles.
25/// `γ = 0` is the Pareto. Matches `pLALocPareto(x, t, alpha_0, gamma)` in
26/// the R package LocalPareto.
27///
28/// In `L` the survival function is a Gaussian, so every layer moment
29/// reduces to normal tail probabilities, evaluated through the Mills ratio
30/// so that layers far in the tail keep full relative precision.
31///
32/// ```
33/// use prospicio_prob::{Distribution, LogAffinePareto, Severity};
34///
35/// // Alpha 1.5 at 1m, rising by 0.5 per doubling.
36/// let d = LogAffinePareto::from_delta(1e6, 1.5, 0.5).unwrap();
37/// assert!((d.local_alpha(2e6) - 2.0).abs() < 1e-14);
38/// assert!(d.layer(4e6, 1e6) > 0.0 && d.mean().is_finite());
39/// ```
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct LogAffinePareto {
42    t: f64,
43    alpha0: f64,
44    gamma: f64,
45}
46
47impl LogAffinePareto {
48    /// Threshold `t > 0`, alpha `α₀ > 0` at the threshold and `γ ≥ 0` (a
49    /// falling alpha would turn negative far out).
50    pub fn new(t: f64, alpha0: f64, gamma: f64) -> Result<Self> {
51        if !t.is_finite() || t <= 0.0 {
52            return Err(invalid("t", t, "must be finite and positive"));
53        }
54        if !alpha0.is_finite() || alpha0 <= 0.0 {
55            return Err(invalid("alpha0", alpha0, "must be finite and positive"));
56        }
57        if !gamma.is_finite() || gamma < 0.0 {
58            return Err(invalid("gamma", gamma, "must be finite and non-negative"));
59        }
60        Ok(Self { t, alpha0, gamma })
61    }
62
63    /// The same distribution from `δ = α₀ γ ln 2 ≥ 0`, the increase in
64    /// the local alpha when the amount doubles.
65    pub fn from_delta(t: f64, alpha0: f64, delta: f64) -> Result<Self> {
66        if !alpha0.is_finite() || alpha0 <= 0.0 {
67            return Err(invalid("alpha0", alpha0, "must be finite and positive"));
68        }
69        Self::new(t, alpha0, delta / (alpha0 * LN_2))
70    }
71
72    pub fn t(&self) -> f64 {
73        self.t
74    }
75
76    /// Local alpha at the threshold.
77    pub fn alpha0(&self) -> f64 {
78        self.alpha0
79    }
80
81    pub fn gamma(&self) -> f64 {
82        self.gamma
83    }
84
85    /// `δ = α₀ γ ln 2`.
86    pub fn delta(&self) -> f64 {
87        self.alpha0 * self.gamma * LN_2
88    }
89
90    /// The local Pareto alpha `−x S'(x) / S(x)` at `x ≥ t` (0 below).
91    pub fn local_alpha(&self, x: f64) -> f64 {
92        if x < self.t {
93            return 0.0;
94        }
95        self.alpha0 * (1.0 + self.gamma * (x / self.t).ln())
96    }
97
98    /// `∫_a^b x^k S(x) dx` for `k ∈ {0, 1}` and `0 ≤ a ≤ b ≤ ∞`.
99    fn integral(&self, k: i32, a: f64, b: f64) -> f64 {
100        if a >= b {
101            return 0.0;
102        }
103        let t = self.t;
104        let below = if a < t {
105            power_integral(k, a, b.min(t))
106        } else {
107            0.0
108        };
109        let lo = a.max(t);
110        if lo >= b {
111            return below;
112        }
113        let c = self.alpha0 * self.gamma;
114        if c == 0.0 {
115            return below + raw_integral(k, t, self.alpha0, lo, b);
116        }
117        // x = t e^L: t^(k+1) ∫ exp(β L − ½ c L²) dL with β = k + 1 − α₀.
118        let beta = f64::from(k) + 1.0 - self.alpha0;
119        let (l1, l2) = ((lo / t).ln(), (b / t).ln());
120        below + t.powi(k + 1) * gaussian_integral(beta, c, l1, l2)
121    }
122}
123
124/// `∫_{l1}^{l2} exp(β L − ½ c L²) dL` for `c > 0` and `l1 < l2 ≤ ∞`.
125///
126/// With `μ = β/c`, `σ = 1/√c`, `z = (L − μ)/σ` and `h(L)` the log of the
127/// integrand, a normal tail `Q(z) = φ(z) R(z)` (`R` the Mills ratio)
128/// turns `σ √(2π) e^(β²/2c) Q(z)` into `σ e^(h(L)) R(z)`, which stays
129/// accurate when the integrand is tiny.
130fn gaussian_integral(beta: f64, c: f64, l1: f64, l2: f64) -> f64 {
131    let sigma = 1.0 / c.sqrt();
132    let mu = beta / c;
133    let h = |l: f64| beta * l - 0.5 * c * l * l;
134    // σ e^h(L) R(|z|): the tail beyond L, away from the peak.
135    let tail = |l: f64| -> f64 {
136        if l.is_infinite() {
137            return 0.0;
138        }
139        sigma * h(l).exp() * mills((l - mu).abs() / sigma)
140    };
141    let (z1, z2) = ((l1 - mu) / sigma, (l2 - mu) / sigma);
142    if z1 >= 0.0 {
143        // Both above the peak: Q(z1) − Q(z2).
144        tail(l1) - tail(l2)
145    } else if z2 <= 0.0 {
146        // Both below the peak: Q(−z2) − Q(−z1).
147        tail(l2) - tail(l1)
148    } else {
149        // Across the peak: 1 − Q(−z1) − Q(z2).
150        sigma * (2.0 * PI).sqrt() * (0.5 * beta * mu).exp() - tail(l1) - tail(l2)
151    }
152}
153
154/// The Mills ratio `R(z) = Q(z) / φ(z)` for `z ≥ 0`, by a continued
155/// fraction where `φ` underflows.
156fn mills(z: f64) -> f64 {
157    if z < 26.0 {
158        return norm_cdf(-z) / norm_pdf(z);
159    }
160    // R(z) = 1 / (z + 1 / (z + 2 / (z + 3 / (z + …)))).
161    let mut r = 0.0;
162    for n in (1..=60).rev() {
163        r = f64::from(n) / (z + r);
164    }
165    1.0 / (z + r)
166}
167
168impl Distribution for LogAffinePareto {
169    fn mean(&self) -> f64 {
170        self.integral(0, 0.0, f64::INFINITY)
171    }
172
173    fn variance(&self) -> f64 {
174        let m = self.mean();
175        if m == f64::INFINITY {
176            return f64::INFINITY;
177        }
178        2.0 * self.integral(1, 0.0, f64::INFINITY) - m * m
179    }
180
181    fn cdf(&self, x: f64) -> f64 {
182        1.0 - self.survival(x)
183    }
184
185    fn survival(&self, x: f64) -> f64 {
186        if x < self.t {
187            return 1.0;
188        }
189        let l = (x / self.t).ln();
190        (-self.alpha0 * l * (1.0 + 0.5 * self.gamma * l)).exp()
191    }
192
193    /// Solves `½ α₀ γ L² + α₀ L + ln s = 0` for `L ≥ 0` in the form
194    /// `L = −2 ln s / (α₀ + √(α₀² − 2 α₀ γ ln s))`, which has no
195    /// cancellation and covers `γ = 0`.
196    fn quantile(&self, p: f64) -> Result<f64> {
197        check_probability(p)?;
198        if p == 1.0 {
199            return Ok(f64::INFINITY);
200        }
201        let log_s = (-p).ln_1p();
202        let a = self.alpha0;
203        let l = -2.0 * log_s / (a + (a * a - 2.0 * a * self.gamma * log_s).sqrt());
204        Ok(self.t * l.exp())
205    }
206}
207
208impl Severity for LogAffinePareto {
209    fn lev(&self, limit: f64) -> f64 {
210        if limit <= 0.0 {
211            return limit;
212        }
213        self.integral(0, 0.0, limit)
214    }
215
216    fn stop_loss(&self, retention: f64) -> f64 {
217        if retention <= 0.0 {
218            return self.mean() - retention;
219        }
220        self.integral(0, retention, f64::INFINITY)
221    }
222
223    fn layer(&self, limit: f64, attachment: f64) -> f64 {
224        let a = attachment.max(0.0);
225        self.integral(0, a, a + limit)
226    }
227
228    /// `2 ∫_a^b (x − a) S(x) dx` with `b = a + limit`.
229    fn layer_second_moment(&self, limit: f64, attachment: f64) -> f64 {
230        let a = attachment.max(0.0);
231        let b = a + limit;
232        2.0 * (self.integral(1, a, b) - a * self.integral(0, a, b))
233    }
234}
235
236/// Settings for [`local_pareto_to_piecewise`].
237#[derive(Debug, Clone, Copy, PartialEq)]
238pub struct LocalParetoConversion {
239    /// Largest relative error allowed in the survival function on the
240    /// approximated range.
241    pub rel_tolerance: f64,
242    /// Stop once the survival function falls below this.
243    pub stop_survival: f64,
244    /// Stop at this amount.
245    pub stop_at: f64,
246}
247
248impl Default for LocalParetoConversion {
249    /// Relative error `1e-4`, approximated until `S < 1e-9` (or forever).
250    fn default() -> Self {
251        Self {
252            rel_tolerance: 1e-4,
253            stop_survival: 1e-9,
254            stop_at: f64::INFINITY,
255        }
256    }
257}
258
259/// A piecewise Pareto approximation of a local Pareto distribution, with
260/// the largest relative error of its survival function found at the
261/// checked points.
262#[derive(Debug, Clone, PartialEq)]
263pub struct LocalParetoApproximation {
264    pub severity: PiecewisePareto,
265    pub max_relative_error: f64,
266    /// Where the approximated range ends: above it the last piece's alpha,
267    /// the local alpha there, continues unchanged.
268    pub approximated_to: f64,
269}
270
271/// Converts the local Pareto distribution with local alpha `alpha(x)` above
272/// the threshold `t` (and `P(X > x) = 1` below it) to a piecewise Pareto.
273///
274/// With `L = ln(x/t)`, `ln S = −A(L)` where `A(L) = ∫_0^L α(t e^v) dv`. A
275/// piecewise Pareto is a chord interpolant of `A` that matches `S` exactly
276/// at its thresholds, so its relative error in `S` on a piece is
277/// `exp(d) − 1` with `d` the gap between `A` and its chord. Pieces are
278/// grown greedily, each as long as possible with that error within
279/// `rel_tolerance` at 16 checked interior points, `A` coming from 8-point
280/// Gauss–Legendre integration. The conversion stops at
281/// `options.stop_at` or where `S` falls below `options.stop_survival`, and
282/// the local alpha there continues as the tail. `alpha` must be finite and
283/// non-negative, and positive at the end.
284///
285/// ```
286/// use prospicio_prob::{Distribution, LogAffinePareto, local_pareto_to_piecewise, LocalParetoConversion};
287///
288/// let exact = LogAffinePareto::new(1000.0, 1.5, 0.4).unwrap();
289/// let approx = local_pareto_to_piecewise(1000.0, |x| exact.local_alpha(x),
290///     LocalParetoConversion::default()).unwrap();
291/// let x = 7_777.0;
292/// assert!((approx.severity.survival(x) / exact.survival(x) - 1.0).abs() < 1e-4);
293/// ```
294pub fn local_pareto_to_piecewise(
295    t: f64,
296    alpha: impl Fn(f64) -> f64,
297    options: LocalParetoConversion,
298) -> Result<LocalParetoApproximation> {
299    if !t.is_finite() || t <= 0.0 {
300        return Err(invalid("t", t, "must be finite and positive"));
301    }
302    let tol = options.rel_tolerance;
303    if !(tol > 0.0 && tol < 1.0) {
304        return Err(invalid("rel_tolerance", tol, "must be in (0, 1)"));
305    }
306    if !(options.stop_survival > 0.0 && options.stop_survival < 1.0) {
307        return Err(invalid(
308            "stop_survival",
309            options.stop_survival,
310            "must be in (0, 1)",
311        ));
312    }
313    if options.stop_at.is_nan() || options.stop_at <= t {
314        return Err(invalid("stop_at", options.stop_at, "must be above t"));
315    }
316    let a = |v: f64| -> Result<f64> {
317        let x = t * v.exp();
318        let al = alpha(x);
319        if !al.is_finite() || al < 0.0 {
320            return Err(invalid("alpha", al, "must be finite and non-negative"));
321        }
322        Ok(al)
323    };
324    let integrate = |lo: f64, hi: f64| -> Result<f64> { gauss_legendre(&a, lo, hi) };
325    let l_stop = (options.stop_at / t).ln();
326    let (mut thresholds, mut alphas) = (vec![t], Vec::new());
327    let (mut l0, mut a0) = (0.0f64, 0.0f64);
328    let mut h = 0.1f64;
329    let mut max_err = 0.0f64;
330    // One piece [l0, l0 + h]: (rise of A, largest relative error in S).
331    let piece = |l0: f64, h: f64| -> Result<(f64, f64)> {
332        const CHECKS: usize = 16;
333        let mut cum = Vec::with_capacity(CHECKS);
334        let mut acc = 0.0;
335        for j in 0..CHECKS {
336            let (lo, hi) = (
337                h * j as f64 / CHECKS as f64,
338                h * (j + 1) as f64 / CHECKS as f64,
339            );
340            acc += integrate(l0 + lo, l0 + hi)?;
341            cum.push(acc);
342        }
343        let rise = acc;
344        let slope = rise / h;
345        let gap = cum[..CHECKS - 1]
346            .iter()
347            .enumerate()
348            .map(|(j, &c)| (c - slope * h * (j + 1) as f64 / CHECKS as f64).abs())
349            .fold(0.0, f64::max);
350        Ok((rise, gap.exp_m1()))
351    };
352    for _ in 0..100_000 {
353        if l0 >= l_stop || -a0 < options.stop_survival.ln() {
354            break;
355        }
356        // Grow while the piece is within tolerance, then shrink until it is.
357        let mut ok = piece(l0, h)?;
358        if ok.1 <= tol {
359            loop {
360                let next = piece(l0, 2.0 * h)?;
361                if next.1 > tol || h > 1e3 {
362                    break;
363                }
364                h *= 2.0;
365                ok = next;
366            }
367        } else {
368            while ok.1 > tol {
369                h *= 0.5;
370                if h < 1e-12 {
371                    return Err(invalid(
372                        "alpha",
373                        t * l0.exp(),
374                        "varies too fast to approximate here",
375                    ));
376                }
377                ok = piece(l0, h)?;
378            }
379        }
380        // Never step past the stop point.
381        if l0 + h > l_stop {
382            h = l_stop - l0;
383            ok = piece(l0, h)?;
384        }
385        alphas.push(ok.0 / h);
386        max_err = max_err.max(ok.1);
387        l0 += h;
388        a0 += ok.0;
389        thresholds.push(t * l0.exp());
390    }
391    let x_end = t * l0.exp();
392    let tail = a(l0)?;
393    if tail <= 0.0 {
394        return Err(invalid(
395            "alpha",
396            tail,
397            "must be positive where the conversion stops",
398        ));
399    }
400    alphas.push(tail);
401    Ok(LocalParetoApproximation {
402        severity: PiecewisePareto::new(thresholds, alphas)?,
403        max_relative_error: max_err,
404        approximated_to: x_end,
405    })
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::Pareto;
412
413    fn close(a: f64, b: f64, rel: f64) -> bool {
414        (a - b).abs() <= rel * b.abs().max(1e-300)
415    }
416
417    #[test]
418    fn gamma_zero_is_the_pareto() {
419        let d = LogAffinePareto::new(1000.0, 2.5, 0.0).unwrap();
420        let p = Pareto::new(1000.0, 2.5).unwrap();
421        for (l, a) in [
422            (4000.0, 1000.0),
423            (1e5, 5e4),
424            (f64::INFINITY, 2000.0),
425            (500.0, 0.0),
426        ] {
427            assert!(close(d.layer(l, a), p.layer(l, a), 1e-14));
428        }
429        assert!(close(d.variance(), p.variance(), 1e-13));
430        assert!(close(
431            d.quantile(0.9).unwrap(),
432            p.quantile(0.9).unwrap(),
433            1e-14
434        ));
435    }
436
437    #[test]
438    fn layers_match_quadrature() {
439        // Composite Simpson in L on a fine grid.
440        let simpson = |d: &LogAffinePareto, k: i32, a: f64, b: f64| -> f64 {
441            let (l1, l2) = ((a / d.t).ln(), (b / d.t).ln());
442            let n = 20_000;
443            let h = (l2 - l1) / f64::from(n);
444            let f = |l: f64| {
445                let x = d.t * l.exp();
446                x.powi(k) * d.survival(x) * x
447            };
448            let mut s = f(l1) + f(l2);
449            for i in 1..n {
450                s += f(l1 + h * f64::from(i)) * if i % 2 == 1 { 4.0 } else { 2.0 };
451            }
452            s * h / 3.0
453        };
454        for (alpha0, gamma) in [(0.5, 0.3), (1.0, 1.0), (2.0, 0.05), (0.2, 2.0), (3.0, 0.5)] {
455            let d = LogAffinePareto::new(1000.0, alpha0, gamma).unwrap();
456            for (a, b) in [(1000.0, 2000.0), (1500.0, 9000.0), (5e4, 2e5), (2e3, 2.1e3)] {
457                let want = simpson(&d, 0, a, b);
458                assert!(
459                    close(d.layer(b - a, a), want, 1e-10),
460                    "{alpha0} {gamma} {a} {b}"
461                );
462                let m2 = 2.0 * (simpson(&d, 1, a, b) - a * want);
463                assert!(
464                    close(d.layer_second_moment(b - a, a), m2, 1e-9),
465                    "{alpha0} {gamma} {a} {b}"
466                );
467            }
468        }
469    }
470
471    #[test]
472    fn identities_and_round_trip() {
473        let d = LogAffinePareto::from_delta(1000.0, 0.8, 0.6).unwrap();
474        assert!(close(d.delta(), 0.6, 1e-15));
475        for x in [500.0, 2000.0, 1e5] {
476            assert!(close(d.lev(x) + d.stop_loss(x), d.mean(), 1e-12));
477        }
478        let m2 = d.layer_second_moment(f64::INFINITY, 0.0);
479        assert!(close(m2 - d.mean() * d.mean(), d.variance(), 1e-12));
480        for p in [0.0, 0.3, 0.9, 0.999_999] {
481            let x = d.quantile(p).unwrap();
482            assert!(close(d.cdf(x), p, 1e-12) || p == 0.0);
483        }
484        // Every moment is finite once γ > 0, even with α₀ < 1.
485        assert!(
486            LogAffinePareto::new(1.0, 0.3, 0.1)
487                .unwrap()
488                .variance()
489                .is_finite()
490        );
491        // Far in the tail (S ≈ 1e-29 at 1e8) a layer stays positive and
492        // below limit · S(attachment).
493        let far = d.layer(1e8, 1e8);
494        assert!(far > 0.0 && far < 1e8 * d.survival(1e8), "{far}");
495    }
496
497    #[test]
498    fn mills_ratio_is_continuous() {
499        // R changes by about −1/z² per unit of z: 4e-14 over this step.
500        let below = mills(26.0 - 1e-12);
501        let above = mills(26.0);
502        assert!(close(below, above, 1e-12), "{below} {above}");
503        assert!(close(mills(0.0), (PI / 2.0).sqrt(), 1e-15));
504    }
505
506    #[test]
507    fn rejects_bad_parameters() {
508        assert!(LogAffinePareto::new(0.0, 1.0, 0.0).is_err());
509        assert!(LogAffinePareto::new(1.0, 0.0, 0.0).is_err());
510        assert!(LogAffinePareto::new(1.0, 1.0, -0.1).is_err());
511        assert!(LogAffinePareto::from_delta(1.0, 1.0, -0.1).is_err());
512    }
513
514    #[test]
515    fn conversion_reproduces_the_log_affine_survival() {
516        let exact = LogAffinePareto::new(1000.0, 1.2, 0.6).unwrap();
517        for tol in [1e-3, 1e-5] {
518            let opts = LocalParetoConversion {
519                rel_tolerance: tol,
520                ..LocalParetoConversion::default()
521            };
522            let approx = local_pareto_to_piecewise(1000.0, |x| exact.local_alpha(x), opts).unwrap();
523            assert!(approx.max_relative_error <= tol);
524            let end = approx.approximated_to;
525            assert!(exact.survival(end) < 1.0001e-9);
526            let mut x = 1000.0;
527            while x < end {
528                let r = approx.severity.survival(x) / exact.survival(x) - 1.0;
529                assert!(r.abs() <= 1.01 * tol, "{tol} {x} {r}");
530                x *= 1.07;
531            }
532            // Matches exactly at every threshold.
533            for &th in approx.severity.thresholds() {
534                if th < end {
535                    let r = approx.severity.survival(th) / exact.survival(th) - 1.0;
536                    assert!(r.abs() < 1e-12, "{th} {r}");
537                }
538            }
539        }
540    }
541
542    #[test]
543    fn conversion_of_constant_and_wavy_alphas() {
544        // A constant alpha is one Pareto, at any tolerance.
545        let approx = local_pareto_to_piecewise(
546            10.0,
547            |_| 2.5,
548            LocalParetoConversion {
549                stop_at: 1e4,
550                ..LocalParetoConversion::default()
551            },
552        )
553        .unwrap();
554        assert!(
555            approx
556                .severity
557                .alphas()
558                .iter()
559                .all(|&a| (a - 2.5).abs() < 1e-12)
560        );
561        assert!(approx.max_relative_error < 1e-12);
562        // A wavy alpha: the survival function by direct integration.
563        let alpha = |x: f64| 1.5 + 0.8 * (x / 100.0).ln().sin();
564        let opts = LocalParetoConversion {
565            rel_tolerance: 1e-6,
566            stop_survival: 1e-6,
567            ..LocalParetoConversion::default()
568        };
569        let approx = local_pareto_to_piecewise(100.0, alpha, opts).unwrap();
570        for x in [150.0, 1000.0, 1e4, 3e4] {
571            if x > approx.approximated_to {
572                continue;
573            }
574            // A(L) = 1.5 L + 0.8 (1 − cos L).
575            let l = (x / 100.0f64).ln();
576            let want = (-(1.5 * l + 0.8 * (1.0 - l.cos()))).exp();
577            let r = approx.severity.survival(x) / want - 1.0;
578            assert!(r.abs() <= 1.01e-6, "{x} {r}");
579        }
580        assert!(
581            local_pareto_to_piecewise(1.0, |_| -1.0, LocalParetoConversion::default()).is_err()
582        );
583        assert!(local_pareto_to_piecewise(0.0, |_| 1.0, LocalParetoConversion::default()).is_err());
584    }
585}