Skip to main content

prospicio_prob/
large_losses.rs

1//! Large-loss data with the two usual defects, and maximum likelihood
2//! fits of Pareto-family severities to it (see `docs/design/pareto.md`).
3//!
4//! - **Reporting thresholds:** loss `i` was only recorded because it
5//!   exceeded `r_i`, so its likelihood is conditional on `X > r_i`.
6//! - **Censoring:** a loss capped by a policy limit is known only to be at
7//!   least its recorded value, and contributes `P(X ≥ y_i | X > r_i)`.
8//!
9//! Weights count a loss several times (or a fraction of a time).
10
11use prospicio_core::Result;
12use prospicio_math::roots::{bisect, bisect_log, illinois};
13
14use crate::evt::Gpd;
15use crate::pareto::invalid;
16use crate::piecewise_pareto::Truncation;
17use crate::{Pareto, PiecewisePareto};
18
19/// Large losses for maximum likelihood fits: values, and optionally a
20/// reporting threshold, a censoring flag and a weight per loss.
21///
22/// ```
23/// use prospicio_prob::{LargeLosses, Pareto};
24///
25/// let data = LargeLosses::new(vec![1500.0, 2500.0, 4000.0, 10_000.0])
26///     .unwrap()
27///     .censored(vec![false, false, false, true])
28///     .unwrap();
29/// // α = (uncensored count) / Σ ln(y / t) = 3 / ln(1.5 · 2.5 · 4 · 10).
30/// let fit = Pareto::fit(1000.0, &data, None).unwrap();
31/// assert!((fit.alpha() - 3.0 / 150f64.ln()).abs() < 1e-14);
32/// ```
33#[derive(Debug, Clone, PartialEq)]
34pub struct LargeLosses {
35    values: Vec<f64>,
36    reporting: Vec<f64>,
37    censored: Vec<bool>,
38    weights: Vec<f64>,
39}
40
41impl LargeLosses {
42    /// Losses (finite, positive), uncensored, with weight 1 and no
43    /// reporting threshold beyond the fitted distribution's own.
44    pub fn new(values: Vec<f64>) -> Result<Self> {
45        if values.is_empty() {
46            return Err(invalid("losses", 0.0, "need at least one loss"));
47        }
48        for &x in &values {
49            if !x.is_finite() || x <= 0.0 {
50                return Err(invalid("losses", x, "must be finite and positive"));
51            }
52        }
53        let n = values.len();
54        Ok(Self {
55            values,
56            reporting: vec![0.0; n],
57            censored: vec![false; n],
58            weights: vec![1.0; n],
59        })
60    }
61
62    /// Per-loss reporting thresholds `r_i ≤ y_i` (finite, non-negative).
63    pub fn reporting_thresholds(self, reporting: Vec<f64>) -> Result<Self> {
64        self.check_len(reporting.len())?;
65        for (&r, &x) in reporting.iter().zip(&self.values) {
66            if !r.is_finite() || r < 0.0 || r > x {
67                return Err(invalid(
68                    "reporting_thresholds",
69                    r,
70                    "must be finite, non-negative and at most the loss",
71                ));
72            }
73        }
74        Ok(Self { reporting, ..self })
75    }
76
77    /// Per-loss censoring: `true` where the loss was capped by a policy
78    /// limit at its recorded value.
79    pub fn censored(self, censored: Vec<bool>) -> Result<Self> {
80        self.check_len(censored.len())?;
81        Ok(Self { censored, ..self })
82    }
83
84    /// Per-loss weights (finite, positive).
85    pub fn weights(self, weights: Vec<f64>) -> Result<Self> {
86        self.check_len(weights.len())?;
87        for &w in &weights {
88            if !w.is_finite() || w <= 0.0 {
89                return Err(invalid("weights", w, "must be finite and positive"));
90            }
91        }
92        Ok(Self { weights, ..self })
93    }
94
95    pub fn values(&self) -> &[f64] {
96        &self.values
97    }
98
99    pub fn len(&self) -> usize {
100        self.values.len()
101    }
102
103    pub fn is_empty(&self) -> bool {
104        self.values.is_empty()
105    }
106
107    fn check_len(&self, n: usize) -> Result<()> {
108        if n != self.values.len() {
109            return Err(invalid("length", n as f64, "must have one entry per loss"));
110        }
111        Ok(())
112    }
113
114    /// `(y, r, censored, w)` per loss, with `r` raised to at least `t`;
115    /// fails if a loss lies below `t`.
116    fn above(&self, t: f64) -> Result<Vec<(f64, f64, bool, f64)>> {
117        (0..self.len())
118            .map(|i| {
119                let y = self.values[i];
120                if y < t {
121                    return Err(invalid(
122                        "losses",
123                        y,
124                        "must be at least the lowest threshold of the fitted distribution",
125                    ));
126                }
127                Ok((
128                    y,
129                    self.reporting[i].max(t),
130                    self.censored[i],
131                    self.weights[i],
132                ))
133            })
134            .collect()
135    }
136}
137
138/// Bounds for alphas found numerically (truncated fits).
139const ALPHA_MIN: f64 = 1e-3;
140const ALPHA_MAX: f64 = 1e3;
141
142impl Pareto {
143    /// Maximum likelihood fit of the alpha of `Pareto(t, α)`, optionally
144    /// truncated at `truncation`, to losses at or above `t`.
145    ///
146    /// Each loss is conditioned on exceeding its reporting threshold
147    /// raised to `t`, so `t` itself matters only through that floor.
148    /// Untruncated, the estimate is in closed form,
149    /// `α = Σ_uncensored w / Σ w ln(y / r)`. Truncated, the score is
150    /// solved by bisection and the estimate clamped to `[1e-3, 1e3]`, as
151    /// in the R package Pareto (whose upper bound is 10).
152    pub fn fit(t: f64, data: &LargeLosses, truncation: Option<f64>) -> Result<Self> {
153        let base = Pareto::new(t, 1.0)?;
154        let losses = data.above(t)?;
155        let alpha = match truncation {
156            None => closed_form_alpha(&losses)?,
157            Some(tr) => {
158                base.truncated(tr)?;
159                truncated_alpha(&losses, tr)?
160            }
161        };
162        let p = Pareto::new(t, alpha)?;
163        match truncation {
164            None => Ok(p),
165            Some(tr) => p.truncated(tr),
166        }
167    }
168}
169
170impl PiecewisePareto {
171    /// Maximum likelihood fit of the alphas of a piecewise Pareto with
172    /// thresholds `t` to losses at or above `t[0]`, optionally with the
173    /// last piece truncated at `truncation`.
174    ///
175    /// The likelihood separates by piece: alpha `k` is the uncensored
176    /// weight ending in piece `k` over the weighted log-exposure
177    /// `Σ w ln(min(y, t_{k+1}) / max(r, t_k))⁺` inside it, and a truncated
178    /// last piece is the truncated Pareto fit of the losses reaching it.
179    /// Every piece needs some exposure; a piece where no uncensored loss
180    /// ends gets alpha 0 (the last piece must have one).
181    ///
182    /// Truncation of the whole distribution at `T` couples the alphas,
183    /// since each loss's likelihood is conditioned through `S(r) − S(T)`.
184    /// The fit then maximizes the likelihood by coordinate ascent from the
185    /// untruncated estimates, each alpha by bisection on its analytic
186    /// partial derivative and clamped to `[1e-3, 1e3]` (as the R package
187    /// clamps to its bounds). `S(a) − S(T)` is computed as
188    /// `S(a) (1 − e^D)` with `D` summed piece by piece over `[a, T]`, so
189    /// losses just below `T` keep their precision.
190    ///
191    /// ```
192    /// use prospicio_prob::{LargeLosses, PiecewisePareto};
193    ///
194    /// let data = LargeLosses::new(vec![1200.0, 1500.0, 2500.0, 6000.0]).unwrap();
195    /// let fit = PiecewisePareto::fit(vec![1000.0, 2000.0], &data, None).unwrap();
196    /// // Piece 1: 2 losses end in it; exposure ln 1.2 + ln 1.5 + 2 ln 2.
197    /// let want = 2.0 / (1.2f64.ln() + 1.5f64.ln() + 2.0 * 2f64.ln());
198    /// assert!((fit.alphas()[0] - want).abs() < 1e-14);
199    /// ```
200    pub fn fit(
201        t: Vec<f64>,
202        data: &LargeLosses,
203        truncation: Option<(f64, Truncation)>,
204    ) -> Result<Self> {
205        // Validates the thresholds.
206        PiecewisePareto::new(t.clone(), vec![1.0; t.len()])?;
207        if let Some((tr, Truncation::WholeDistribution)) = truncation {
208            PiecewisePareto::new(t.clone(), vec![1.0; t.len()])?
209                .truncated(tr, Truncation::WholeDistribution)?;
210            let start = PiecewisePareto::fit(t.clone(), data, None)?;
211            let losses = data.above(t[0])?;
212            let alphas = whole_truncated_alphas(&t, &losses, tr, start.alphas())?;
213            return PiecewisePareto::new(t, alphas)?.truncated(tr, Truncation::WholeDistribution);
214        }
215        let losses = data.above(t[0])?;
216        let n = t.len();
217        let mut alphas = Vec::with_capacity(n);
218        for k in 0..n {
219            let (lo, hi) = (t[k], t.get(k + 1).copied().unwrap_or(f64::INFINITY));
220            let last = k + 1 == n;
221            if let (true, Some((tr, _))) = (last, truncation) {
222                // Losses reaching the last piece, conditioned on exceeding
223                // max(r, t_n): a truncated Pareto fit.
224                let reaching: Vec<_> = losses
225                    .iter()
226                    .filter(|&&(y, ..)| y > lo)
227                    .map(|&(y, r, c, w)| (y, r.max(lo), c, w))
228                    .collect();
229                PiecewisePareto::new(t.clone(), vec![1.0; n])?
230                    .truncated(tr, Truncation::LastPiece)?;
231                alphas.push(truncated_alpha(&reaching, tr)?);
232                continue;
233            }
234            let (mut count, mut exposure) = (0.0, 0.0);
235            for &(y, r, censored, w) in &losses {
236                let (a, b) = (r.max(lo), y.min(hi));
237                if b > a {
238                    exposure += w * (b / a).ln();
239                }
240                if !censored && y >= lo && y < hi {
241                    count += w;
242                }
243            }
244            if exposure <= 0.0 {
245                return Err(invalid(
246                    "t",
247                    lo,
248                    "no loss reaches into this piece above its reporting threshold",
249                ));
250            }
251            alphas.push(count / exposure);
252        }
253        let fit = PiecewisePareto::new(t, alphas)?;
254        match truncation {
255            None => Ok(fit),
256            Some((tr, kind)) => fit.truncated(tr, kind),
257        }
258    }
259}
260
261impl Gpd {
262    /// Maximum likelihood fit of Riegel's generalized Pareto
263    /// ([`Gpd::riegel`]) with threshold `t` to losses at or above `t`,
264    /// with the reporting thresholds, censoring and weights of `data`.
265    ///
266    /// With `z = x/t − 1` and `k = α_ini / α_tail`, `S(x) = (1 + k z)^(−α_tail)`.
267    /// For fixed `k` the likelihood is maximized by
268    /// `α_tail(k) = Σ_uncensored w / Σ w ln((1 + k z_y) / (1 + k z_r))`, so
269    /// the fit is a one-dimensional profile likelihood in `k`: a scan on a
270    /// log grid over `[1e-6, 1e6]` brackets the maximum, and bisection on the
271    /// analytic score pins it. `k = 1` is the Pareto.
272    ///
273    /// ```
274    /// use prospicio_core::StreamRng;
275    /// use prospicio_prob::{Distribution, LargeLosses, evt::Gpd};
276    ///
277    /// let truth = Gpd::riegel(1000.0, 3.0, 1.5).unwrap();
278    /// let draws = truth.sample(&mut StreamRng::new(4, 0), 100_000);
279    /// let fit = Gpd::fit_riegel(1000.0, &LargeLosses::new(draws).unwrap()).unwrap();
280    /// // ξ = 1/α_tail, β = t/α_ini.
281    /// assert!((1.0 / fit.xi() / 1.5 - 1.0).abs() < 0.05);
282    /// assert!((1000.0 / fit.beta() / 3.0 - 1.0).abs() < 0.05);
283    /// ```
284    pub fn fit_riegel(t: f64, data: &LargeLosses) -> Result<Self> {
285        Pareto::new(t, 1.0)?;
286        let losses = data.above(t)?;
287        let count: f64 = losses.iter().filter(|l| !l.2).map(|l| l.3).sum();
288        if count == 0.0 {
289            return Err(invalid("losses", 0.0, "need at least one uncensored loss"));
290        }
291        let z = |x: f64| x / t - 1.0;
292        // Σ w ln((1 + k z_y) / (1 + k z_r)) and its derivative in k.
293        let exposure = |k: f64| -> (f64, f64) {
294            losses.iter().fold((0.0, 0.0), |(e, de), &(y, r, _, w)| {
295                let (zy, zr) = (z(y), z(r));
296                (
297                    e + w * ((k * zy).ln_1p() - (k * zr).ln_1p()),
298                    de + w * (zy / (1.0 + k * zy) - zr / (1.0 + k * zr)),
299                )
300            })
301        };
302        // The profile log-likelihood is n ln(k α_tail(k)) − Σ_uncensored w
303        // ln(1 + k z_y) up to a constant; its derivative in k is
304        // n/k − n E'(k)/E(k) − Σ_uncensored w z_y/(1 + k z_y).
305        let score = |k: f64| -> Option<f64> {
306            let (e, de) = exposure(k);
307            if e <= 0.0 {
308                return None;
309            }
310            let own: f64 = losses
311                .iter()
312                .filter(|l| !l.2)
313                .map(|&(y, _, _, w)| w * z(y) / (1.0 + k * z(y)))
314                .sum();
315            Some(count / k - count * de / e - own)
316        };
317        let profile = |k: f64| -> Option<f64> {
318            let (e, _) = exposure(k);
319            if e <= 0.0 {
320                return None;
321            }
322            let alpha_tail = count / e;
323            let own: f64 = losses
324                .iter()
325                .filter(|l| !l.2)
326                .map(|&(y, _, _, w)| w * (k * z(y)).ln_1p())
327                .sum();
328            // At the optimal α_tail the α_tail-terms sum to −n, a constant.
329            Some(count * (k * alpha_tail).ln() - own)
330        };
331        let steps = 480;
332        let (lo_k, hi_k) = (1e-6f64, 1e6f64);
333        let at = |j: usize| (lo_k.ln() + (hi_k / lo_k).ln() * j as f64 / steps as f64).exp();
334        let best = (0..=steps)
335            .filter_map(|j| profile(at(j)).map(|v| (j, v)))
336            .max_by(|a, b| a.1.total_cmp(&b.1))
337            .map(|(j, _)| j)
338            .ok_or_else(|| {
339                invalid(
340                    "losses",
341                    f64::NAN,
342                    "need a loss above its reporting threshold",
343                )
344            })?;
345        if best == 0 || best == steps {
346            return Err(invalid(
347                "losses",
348                at(best),
349                "the likelihood has no maximum with alpha_ini / alpha_tail in [1e-6, 1e6]",
350            ));
351        }
352        // The score falls through 0 at the maximum, between the neighbours.
353        let k = bisect_log(
354            at(best - 1),
355            at(best + 1),
356            |k| matches!(score(k), Some(s) if s > 0.0),
357        );
358        let alpha_tail = count / exposure(k).0;
359        Gpd::riegel(t, k * alpha_tail, alpha_tail)
360    }
361}
362
363/// The alphas of a piecewise Pareto with thresholds `t`, truncated as a
364/// whole at `tr`, that maximize the conditional likelihood of `losses`:
365/// coordinate ascent from `start`, each alpha by bisection on its partial
366/// derivative.
367///
368/// With `E_k(a)` the log-exposure of `[t_0, a]` in piece `k`,
369/// `ln S(a) = −Σ α_k E_k(a)`, and with `ΔE_k(a) = E_k(T) − E_k(a)` and
370/// `D(a) = −Σ α_k ΔE_k(a)`, `ln(S(a) − S(T)) = ln S(a) + ln(1 − e^D(a))`,
371/// whose derivative in `α_k` is `−E_k(a) + ΔE_k(a) / expm1(−D(a))`.
372fn whole_truncated_alphas(
373    t: &[f64],
374    losses: &[(f64, f64, bool, f64)],
375    tr: f64,
376    start: &[f64],
377) -> Result<Vec<f64>> {
378    let n = t.len();
379    let exposures = |a: f64| -> Vec<f64> {
380        (0..n)
381            .map(|k| {
382                let hi = t.get(k + 1).copied().unwrap_or(f64::INFINITY);
383                if a > t[k] {
384                    (a.min(hi) / t[k]).ln()
385                } else {
386                    0.0
387                }
388            })
389            .collect()
390    };
391    let e_tr = exposures(tr);
392    struct Row {
393        w: f64,
394        censored: bool,
395        piece: usize,
396        e_y: Vec<f64>,
397        d_y: Vec<f64>,
398        e_r: Vec<f64>,
399        d_r: Vec<f64>,
400    }
401    let mut rows = Vec::with_capacity(losses.len());
402    for &(y, r, censored, w) in losses {
403        if y >= tr {
404            return Err(invalid("losses", y, "must lie below the truncation point"));
405        }
406        let (e_y, e_r) = (exposures(y), exposures(r));
407        let diff = |e: &[f64]| e_tr.iter().zip(e).map(|(a, b)| a - b).collect::<Vec<_>>();
408        rows.push(Row {
409            w,
410            censored,
411            piece: t.partition_point(|&x| x <= y) - 1,
412            d_y: diff(&e_y),
413            d_r: diff(&e_r),
414            e_y,
415            e_r,
416        });
417    }
418    if rows.iter().all(|r| r.censored) {
419        return Err(invalid("losses", 0.0, "need at least one uncensored loss"));
420    }
421    let partial = |alphas: &[f64], k: usize| -> f64 {
422        // ∂/∂α_k of ln(S(a) − S(T)).
423        let term = |e: &[f64], d: &[f64]| -> f64 {
424            let minus_d: f64 = alphas.iter().zip(d).map(|(a, x)| a * x).sum();
425            -e[k] + d[k] / minus_d.exp_m1()
426        };
427        rows.iter()
428            .map(|r| {
429                let own = if r.censored {
430                    term(&r.e_y, &r.d_y)
431                } else {
432                    let count = if r.piece == k { 1.0 / alphas[k] } else { 0.0 };
433                    count - r.e_y[k]
434                };
435                r.w * (own - term(&r.e_r, &r.d_r))
436            })
437            .sum()
438    };
439    let mut alphas: Vec<f64> = start
440        .iter()
441        .map(|&a| a.clamp(ALPHA_MIN, ALPHA_MAX))
442        .collect();
443    for _ in 0..5000 {
444        let mut change = 0.0f64;
445        for k in 0..n {
446            let old = alphas[k];
447            let at = |a: f64, alphas: &mut Vec<f64>| {
448                alphas[k] = a;
449                partial(alphas, k)
450            };
451            let new = coordinate_root(old, |a| at(a, &mut alphas));
452            alphas[k] = new;
453            change = change.max((new / old).ln().abs());
454        }
455        if change < 1e-13 {
456            break;
457        }
458    }
459    Ok(alphas)
460}
461
462/// The root in `[ALPHA_MIN, ALPHA_MAX]` of a partial derivative `f` that
463/// falls through 0 at the coordinate's maximum, or the bound it is clamped
464/// to. Brackets around `start` first, then the Illinois method on
465/// `ln α`, which converges in a handful of evaluations.
466fn coordinate_root(start: f64, mut f: impl FnMut(f64) -> f64) -> f64 {
467    let (mut lo, mut hi) = ((start / 1.5).max(ALPHA_MIN), (start * 1.5).min(ALPHA_MAX));
468    let (mut f_lo, mut f_hi) = (f(lo), f(hi));
469    while f_lo <= 0.0 && lo > ALPHA_MIN {
470        hi = lo;
471        f_hi = f_lo;
472        lo = (lo / 4.0).max(ALPHA_MIN);
473        f_lo = f(lo);
474    }
475    if f_lo <= 0.0 {
476        return ALPHA_MIN;
477    }
478    while f_hi > 0.0 && hi < ALPHA_MAX {
479        lo = hi;
480        f_lo = f_hi;
481        hi = (hi * 4.0).min(ALPHA_MAX);
482        f_hi = f(hi);
483    }
484    if f_hi > 0.0 {
485        return ALPHA_MAX;
486    }
487    // Illinois on x = ln α: f(lo) > 0 ≥ f(hi).
488    illinois(lo.ln(), hi.ln(), f_lo, f_hi, |x| f(x.exp())).exp()
489}
490
491/// `Σ_uncensored w / Σ w ln(y / r)`.
492fn closed_form_alpha(losses: &[(f64, f64, bool, f64)]) -> Result<f64> {
493    let count: f64 = losses.iter().filter(|l| !l.2).map(|l| l.3).sum();
494    let exposure: f64 = losses.iter().map(|&(y, r, _, w)| w * (y / r).ln()).sum();
495    if count == 0.0 {
496        return Err(invalid("losses", 0.0, "need at least one uncensored loss"));
497    }
498    if exposure <= 0.0 {
499        return Err(invalid(
500            "losses",
501            exposure,
502            "need a loss above its reporting threshold",
503        ));
504    }
505    Ok(count / exposure)
506}
507
508/// The alpha of a Pareto truncated at `tr` that maximizes the conditional
509/// likelihood of `losses` (each above its `r`), by bisection on the score,
510/// clamped to `[ALPHA_MIN, ALPHA_MAX]`. (A truncated Pareto is a
511/// distribution for any alpha, and data that rises towards the truncation
512/// point can put the maximum at or below 0.)
513///
514/// With `g(r) = d/dα ln(r^(−α) − T^(−α)) = −ln r + ln(T/r) q/(1 − q)` and
515/// `q = (r/T)^α`, an uncensored loss contributes `1/α − ln y − g(r)` and a
516/// censored one `g(y) − g(r)`.
517fn truncated_alpha(losses: &[(f64, f64, bool, f64)], tr: f64) -> Result<f64> {
518    if losses.iter().all(|l| l.2) {
519        return Err(invalid("losses", 0.0, "need at least one uncensored loss"));
520    }
521    for &(y, ..) in losses {
522        if y >= tr {
523            return Err(invalid("losses", y, "must lie below the truncation point"));
524        }
525    }
526    let g = |x: f64, alpha: f64| -> f64 {
527        let log_q = alpha * (x / tr).ln();
528        // q / (1 − q) = 1 / expm1(−ln q).
529        -x.ln() + (tr / x).ln() / (-log_q).exp_m1()
530    };
531    let score = |alpha: f64| -> f64 {
532        losses
533            .iter()
534            .map(|&(y, r, censored, w)| {
535                let own = if censored {
536                    g(y, alpha)
537                } else {
538                    1.0 / alpha - y.ln()
539                };
540                w * (own - g(r, alpha))
541            })
542            .sum()
543    };
544    // The score falls through 0 at the maximum: find the first sign change
545    // on a log grid, then bisect.
546    let steps = 240;
547    let at =
548        |j: usize| (ALPHA_MIN.ln() + (ALPHA_MAX / ALPHA_MIN).ln() * j as f64 / steps as f64).exp();
549    if score(at(0)) <= 0.0 {
550        return Ok(ALPHA_MIN);
551    }
552    let Some(j) = (1..=steps).find(|&j| score(at(j)) <= 0.0) else {
553        return Ok(ALPHA_MAX);
554    };
555    Ok(bisect(at(j - 1), at(j), |a| score(a) > 0.0))
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::{Distribution, Severity};
562    use prospicio_core::StreamRng;
563
564    #[test]
565    fn closed_form_pareto_with_thresholds_and_censoring() {
566        let data = LargeLosses::new(vec![1500.0, 3000.0, 2500.0, 8000.0])
567            .unwrap()
568            .reporting_thresholds(vec![0.0, 2000.0, 1000.0, 0.0])
569            .unwrap()
570            .censored(vec![false, false, false, true])
571            .unwrap()
572            .weights(vec![1.0, 2.0, 1.0, 1.0])
573            .unwrap();
574        let fit = Pareto::fit(1000.0, &data, None).unwrap();
575        let exposure = 1.5f64.ln() + 2.0 * 1.5f64.ln() + 2.5f64.ln() + 8f64.ln();
576        assert!((fit.alpha() - 4.0 / exposure).abs() < 1e-14);
577        assert!(Pareto::fit(2000.0, &data, None).is_err());
578        let all_censored = LargeLosses::new(vec![2000.0])
579            .unwrap()
580            .censored(vec![true])
581            .unwrap();
582        assert!(Pareto::fit(1000.0, &all_censored, None).is_err());
583    }
584
585    #[test]
586    fn truncated_fit_maximizes_the_likelihood() {
587        let data = LargeLosses::new(vec![1100.0, 1300.0, 2000.0, 3500.0, 9000.0, 4000.0])
588            .unwrap()
589            .reporting_thresholds(vec![0.0, 1200.0, 0.0, 0.0, 0.0, 0.0])
590            .unwrap()
591            .censored(vec![false, false, false, false, false, true])
592            .unwrap();
593        let tr = 20_000.0;
594        let fit = Pareto::fit(1000.0, &data, Some(tr)).unwrap();
595        let ll = |alpha: f64| -> f64 {
596            let p = Pareto::new(1000.0, alpha).unwrap().truncated(tr).unwrap();
597            let mut ll = 0.0;
598            for (i, &y) in data.values().iter().enumerate() {
599                let r = data.reporting[i].max(1000.0);
600                ll -= p.survival(r).ln();
601                ll += if data.censored[i] {
602                    p.survival(y).ln()
603                } else {
604                    // Density by the closed form: α t^α y^(−α−1) / (1 − q).
605                    let q = (1000.0 / tr).powf(alpha);
606                    (alpha * 1000f64.powf(alpha) * y.powf(-alpha - 1.0) / (1.0 - q)).ln()
607                };
608            }
609            ll
610        };
611        let a = fit.alpha();
612        assert!(ll(a) >= ll(a * 1.001) && ll(a) >= ll(a * 0.999), "{a}");
613        // Truncation far away gives the untruncated answer.
614        let far = Pareto::fit(1000.0, &data, Some(1e30)).unwrap();
615        let open = Pareto::fit(1000.0, &data, None).unwrap();
616        assert!((far.alpha() / open.alpha() - 1.0).abs() < 1e-9);
617        assert!(Pareto::fit(1000.0, &data, Some(5000.0)).is_err());
618    }
619
620    #[test]
621    fn fits_recover_simulated_parameters() {
622        let truth =
623            PiecewisePareto::new(vec![1000.0, 3000.0, 10_000.0], vec![1.2, 2.0, 1.5]).unwrap();
624        let mut rng = StreamRng::new(11, 0);
625        let draws = truth.sample(&mut rng, 200_000);
626        // Policy limits censor at 50,000; a reporting threshold of 2,000 on
627        // every other loss drops the ones below it.
628        let (mut values, mut reporting, mut censored) = (vec![], vec![], vec![]);
629        for (i, &x) in draws.iter().enumerate() {
630            let r = if i % 2 == 0 { 2000.0 } else { 0.0 };
631            if x <= r {
632                continue;
633            }
634            values.push(x.min(50_000.0));
635            reporting.push(r);
636            censored.push(x >= 50_000.0);
637        }
638        let data = LargeLosses::new(values)
639            .unwrap()
640            .reporting_thresholds(reporting)
641            .unwrap()
642            .censored(censored)
643            .unwrap();
644        let fit = PiecewisePareto::fit(vec![1000.0, 3000.0, 10_000.0], &data, None).unwrap();
645        for (got, want) in fit.alphas().iter().zip(truth.alphas()) {
646            assert!((got / want - 1.0).abs() < 0.03, "{got} {want}");
647        }
648        // One piece: the Pareto fit.
649        let one = PiecewisePareto::fit(vec![1000.0], &data, None).unwrap();
650        let p = Pareto::fit(1000.0, &data, None).unwrap();
651        assert!((one.alphas()[0] / p.alpha() - 1.0).abs() < 1e-14);
652        // A truncated last piece.
653        let t = PiecewisePareto::new(vec![1000.0, 3000.0], vec![1.2, 0.8])
654            .unwrap()
655            .truncated(40_000.0, Truncation::LastPiece)
656            .unwrap();
657        let draws = t.sample(&mut StreamRng::new(12, 0), 100_000);
658        let data = LargeLosses::new(draws).unwrap();
659        let fit = PiecewisePareto::fit(
660            vec![1000.0, 3000.0],
661            &data,
662            Some((40_000.0, Truncation::LastPiece)),
663        )
664        .unwrap();
665        assert!(
666            (fit.alphas()[1] / 0.8 - 1.0).abs() < 0.03,
667            "{:?}",
668            fit.alphas()
669        );
670        assert!(fit.layer(1e4, 1e4) > 0.0);
671        // Draws reach 40,000 exactly only below it, so a whole-distribution
672        // fit at the same point works too.
673        assert!(
674            PiecewisePareto::fit(
675                vec![1000.0, 3000.0],
676                &data,
677                Some((40_000.0, Truncation::WholeDistribution)),
678            )
679            .is_ok()
680        );
681    }
682
683    #[test]
684    fn generalized_pareto_fit_maximizes_the_likelihood() {
685        let data = LargeLosses::new(vec![
686            1100.0, 1300.0, 1750.0, 2000.0, 2600.0, 3500.0, 4100.0, 9000.0, 25_000.0, 40_000.0,
687        ])
688        .unwrap()
689        .reporting_thresholds(vec![
690            0.0, 1200.0, 0.0, 1500.0, 0.0, 0.0, 3000.0, 5000.0, 0.0, 0.0,
691        ])
692        .unwrap()
693        .censored(vec![
694            false, false, false, false, false, true, false, false, false, true,
695        ])
696        .unwrap();
697        let t = 1000.0;
698        let ll = |alpha_ini: f64, alpha_tail: f64| -> f64 {
699            let g = Gpd::riegel(t, alpha_ini, alpha_tail).unwrap();
700            let k = alpha_ini / alpha_tail;
701            (0..data.len())
702                .map(|i| {
703                    let (y, r) = (data.values[i], data.reporting[i].max(t));
704                    let own = if data.censored[i] {
705                        g.survival(y).ln()
706                    } else {
707                        (alpha_ini / t).ln() - (alpha_tail + 1.0) * (k * (y / t - 1.0)).ln_1p()
708                    };
709                    own - g.survival(r).ln()
710                })
711                .sum()
712        };
713        let fit = Gpd::fit_riegel(t, &data).unwrap();
714        let (ai, at) = (t / fit.beta(), 1.0 / fit.xi());
715        let best = ll(ai, at);
716        for (da, dt) in [
717            (1.001, 1.0),
718            (0.999, 1.0),
719            (1.0, 1.001),
720            (1.0, 0.999),
721            (1.001, 1.001),
722        ] {
723            assert!(ll(ai * da, at * dt) < best, "{da} {dt}");
724        }
725        // Equal alphas reduce to the Pareto fit's model family: data drawn
726        // from a Pareto give k near 1.
727        let p = Pareto::new(t, 2.0).unwrap();
728        let draws = p.sample(&mut StreamRng::new(9, 0), 100_000);
729        let g = Gpd::fit_riegel(t, &LargeLosses::new(draws).unwrap()).unwrap();
730        assert!(((t / g.beta()) * g.xi() - 1.0).abs() < 0.05);
731    }
732
733    #[test]
734    fn whole_truncated_piecewise_fit_maximizes_the_likelihood() {
735        let t = vec![1000.0, 2500.0, 8000.0];
736        let tr = 60_000.0;
737        let data = LargeLosses::new(vec![
738            1100.0, 1300.0, 1750.0, 2000.0, 2600.0, 3500.0, 4100.0, 5200.0, 7000.0, 9000.0,
739            12_000.0, 18_000.0, 25_000.0, 40_000.0,
740        ])
741        .unwrap()
742        .reporting_thresholds(vec![
743            0.0, 1200.0, 0.0, 1500.0, 0.0, 0.0, 3000.0, 0.0, 0.0, 5000.0, 0.0, 0.0, 0.0, 0.0,
744        ])
745        .unwrap()
746        .censored(vec![
747            false, false, false, false, false, true, false, false, false, false, true, false,
748            false, true,
749        ])
750        .unwrap();
751        let ll = |alphas: &[f64]| -> f64 {
752            let open = PiecewisePareto::new(t.clone(), alphas.to_vec()).unwrap();
753            let pp = open
754                .clone()
755                .truncated(tr, Truncation::WholeDistribution)
756                .unwrap();
757            let mass = 1.0 - open.survival(tr);
758            (0..data.len())
759                .map(|i| {
760                    let (y, r) = (data.values[i], data.reporting[i].max(t[0]));
761                    let own = if data.censored[i] {
762                        pp.survival(y).ln()
763                    } else {
764                        // Density α_k S(y) / (y (1 − S(T))), S untruncated.
765                        let k = t.partition_point(|&x| x <= y) - 1;
766                        (alphas[k] * open.survival(y) / (y * mass)).ln()
767                    };
768                    own - pp.survival(r).ln()
769                })
770                .sum()
771        };
772        let fit = PiecewisePareto::fit(t.clone(), &data, Some((tr, Truncation::WholeDistribution)))
773            .unwrap();
774        let a = fit.alphas().to_vec();
775        let best = ll(&a);
776        for k in 0..3 {
777            for f in [1.001, 0.999] {
778                let mut b = a.clone();
779                b[k] *= f;
780                assert!(ll(&b) < best, "{k} {f}");
781            }
782        }
783        // Recovery from simulated data.
784        let truth = PiecewisePareto::new(t.clone(), vec![1.2, 0.8, 1.5])
785            .unwrap()
786            .truncated(tr, Truncation::WholeDistribution)
787            .unwrap();
788        let draws = truth.sample(&mut StreamRng::new(13, 0), 100_000);
789        let fit = PiecewisePareto::fit(
790            t,
791            &LargeLosses::new(draws).unwrap(),
792            Some((tr, Truncation::WholeDistribution)),
793        )
794        .unwrap();
795        for (got, want) in fit.alphas().iter().zip(truth.alphas()) {
796            assert!((got / want - 1.0).abs() < 0.05, "{got} {want}");
797        }
798    }
799
800    #[test]
801    fn rejects_bad_data() {
802        assert!(LargeLosses::new(vec![]).is_err());
803        assert!(LargeLosses::new(vec![-1.0]).is_err());
804        let d = LargeLosses::new(vec![10.0, 20.0]).unwrap();
805        assert!(d.clone().reporting_thresholds(vec![15.0, 0.0]).is_err());
806        assert!(d.clone().weights(vec![1.0]).is_err());
807        assert!(d.clone().weights(vec![1.0, 0.0]).is_err());
808        // No loss reaches the top piece.
809        assert!(PiecewisePareto::fit(vec![5.0, 50.0], &d, None).is_err());
810    }
811}