Skip to main content

prospicio_models/
metrics.rs

1//! Scores for fitted models: deviance-based, ranking (Gini, lift),
2//! calibration (actual vs expected), and probabilistic (CRPS, coverage,
3//! log score, PIT).
4//!
5//! Every metric takes plain slices, so it scores any engine's output the
6//! same way.
7
8use prospicio_core::{Error, Result, StreamRng};
9use prospicio_math::special::norm_quantile;
10
11use crate::design::Design;
12use crate::family::Family;
13
14/// `Σ w d(y, μ)`, the family's deviance.
15///
16/// ```
17/// use prospicio_models::{Family, metrics};
18///
19/// let d = metrics::deviance(Family::Gaussian, &[1.0, 3.0], &[2.0, 2.0], None).unwrap();
20/// assert_eq!(d, 2.0);
21/// ```
22pub fn deviance(family: Family, y: &[f64], mu: &[f64], weights: Option<&[f64]>) -> Result<f64> {
23    same_length(y.len(), mu.len(), "mu")?;
24    if let Some(w) = weights {
25        same_length(y.len(), w.len(), "weights")?;
26    }
27    Ok(y.iter()
28        .zip(mu)
29        .enumerate()
30        .map(|(i, (&y, &m))| weights.map_or(1.0, |w| w[i]) * family.unit_deviance(y, m))
31        .sum())
32}
33
34/// Weighted mean deviance, `Σ w d / Σ w`: the score to compare models on
35/// held-out data.
36pub fn mean_deviance(
37    family: Family,
38    y: &[f64],
39    mu: &[f64],
40    weights: Option<&[f64]>,
41) -> Result<f64> {
42    let total = weights.map_or(y.len() as f64, |w| w.iter().sum());
43    Ok(deviance(family, y, mu, weights)? / total)
44}
45
46/// Root mean squared error.
47pub fn rmse(y: &[f64], pred: &[f64]) -> Result<f64> {
48    same_length(y.len(), pred.len(), "pred")?;
49    let n = y.len() as f64;
50    Ok((y
51        .iter()
52        .zip(pred)
53        .map(|(a, b)| (a - b) * (a - b))
54        .sum::<f64>()
55        / n)
56        .sqrt())
57}
58
59/// Mean absolute error.
60pub fn mae(y: &[f64], pred: &[f64]) -> Result<f64> {
61    same_length(y.len(), pred.len(), "pred")?;
62    let n = y.len() as f64;
63    Ok(y.iter().zip(pred).map(|(a, b)| (a - b).abs()).sum::<f64>() / n)
64}
65
66/// Weighted mean pinball (quantile) loss of predictions `pred` of the
67/// `alpha` quantile, `Σ w ρ(y − q) / Σ w` with `ρ(u) = u (alpha − 1{u < 0})`.
68/// Its expectation is smallest at the true `alpha` quantile, so it scores a
69/// quantile model as the deviance scores a mean model; at `alpha = 0.5` it
70/// is half the mean absolute error.
71///
72/// ```
73/// use prospicio_models::metrics::pinball;
74///
75/// // Under-predicting the 90% quantile by 1 costs 0.9; over-predicting, 0.1.
76/// assert!((pinball(&[1.0, 0.0], &[0.0, 1.0], 0.9, None).unwrap() - 0.5).abs() < 1e-15);
77/// ```
78pub fn pinball(y: &[f64], pred: &[f64], alpha: f64, weights: Option<&[f64]>) -> Result<f64> {
79    same_length(y.len(), pred.len(), "pred")?;
80    if !(alpha > 0.0 && alpha < 1.0) {
81        return Err(Error::InvalidParameter {
82            name: "alpha",
83            value: alpha,
84            reason: "must be in (0, 1)",
85        });
86    }
87    if let Some(w) = weights {
88        same_length(y.len(), w.len(), "weights")?;
89    }
90    let (mut loss, mut total) = (0.0, 0.0);
91    for (i, (&y, &q)) in y.iter().zip(pred).enumerate() {
92        let w = weights.map_or(1.0, |w| w[i]);
93        let u = y - q;
94        loss += w * u * if u < 0.0 { alpha - 1.0 } else { alpha };
95        total += w;
96    }
97    Ok(loss / total)
98}
99
100/// Gini index of the ordered Lorenz curve: rows sorted by prediction
101/// ascending, cumulative exposure share against cumulative loss share.
102/// Twice the area between the diagonal and the curve, so 0 for a model
103/// that ranks no better than chance and larger for sharper ranking.
104/// Exposure defaults to 1 per row; tied predictions are taken together.
105///
106/// ```
107/// use prospicio_models::metrics::gini;
108///
109/// // Perfect ranking of two risks with losses 0 and 1: area 1/4, Gini 1/2.
110/// assert!((gini(&[0.0, 1.0], &[0.1, 0.9], None).unwrap() - 0.5).abs() < 1e-15);
111/// // A constant prediction ranks nothing.
112/// assert!(gini(&[0.0, 1.0], &[0.5, 0.5], None).unwrap().abs() < 1e-15);
113/// ```
114pub fn gini(y: &[f64], pred: &[f64], exposure: Option<&[f64]>) -> Result<f64> {
115    same_length(y.len(), pred.len(), "pred")?;
116    if let Some(e) = exposure {
117        same_length(y.len(), e.len(), "exposure")?;
118    }
119    let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
120    let mut order: Vec<usize> = (0..y.len()).collect();
121    order.sort_by(|&a, &b| pred[a].total_cmp(&pred[b]));
122    let total_e: f64 = (0..y.len()).map(e).sum();
123    let total_y: f64 = y.iter().sum();
124    if total_e <= 0.0 || total_y == 0.0 {
125        return Err(Error::InvalidParameter {
126            name: "y",
127            value: total_y,
128            reason: "needs positive total exposure and non-zero total loss",
129        });
130    }
131    // Trapezoid area under the Lorenz curve, ties grouped into one step.
132    let (mut area, mut cum_e, mut cum_y) = (0.0, 0.0, 0.0);
133    let mut i = 0;
134    while i < order.len() {
135        let mut j = i;
136        let (mut de, mut dy) = (0.0, 0.0);
137        while j < order.len() && pred[order[j]] == pred[order[i]] {
138            de += e(order[j]);
139            dy += y[order[j]];
140            j += 1;
141        }
142        let (x0, y0) = (cum_e / total_e, cum_y / total_y);
143        cum_e += de;
144        cum_y += dy;
145        let (x1, y1) = (cum_e / total_e, cum_y / total_y);
146        area += 0.5 * (x1 - x0) * (y0 + y1);
147        i = j;
148    }
149    Ok(1.0 - 2.0 * area)
150}
151
152/// One row of a lift table: a band of rows with similar predictions.
153#[derive(Debug, Clone, PartialEq)]
154pub struct LiftBand {
155    /// Total exposure in the band.
156    pub exposure: f64,
157    /// Total predicted loss.
158    pub expected: f64,
159    /// Total actual loss.
160    pub actual: f64,
161}
162
163impl LiftBand {
164    /// `actual / expected`.
165    pub fn ratio(&self) -> f64 {
166        self.actual / self.expected
167    }
168}
169
170/// Lift table: rows sorted by predicted rate (`pred / exposure`) and cut
171/// into `bands` groups of about equal exposure, each with its exposure,
172/// expected and actual totals. A calibrated model has `actual / expected`
173/// near 1 in every band; a sharp one spreads the expected rates widely.
174pub fn lift(
175    y: &[f64],
176    pred: &[f64],
177    exposure: Option<&[f64]>,
178    bands: usize,
179) -> Result<Vec<LiftBand>> {
180    same_length(y.len(), pred.len(), "pred")?;
181    if let Some(e) = exposure {
182        same_length(y.len(), e.len(), "exposure")?;
183    }
184    if bands == 0 {
185        return Err(Error::InvalidParameter {
186            name: "bands",
187            value: 0.0,
188            reason: "must be positive",
189        });
190    }
191    let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
192    let mut order: Vec<usize> = (0..y.len()).collect();
193    order.sort_by(|&a, &b| (pred[a] / e(a)).total_cmp(&(pred[b] / e(b))));
194    let total: f64 = (0..y.len()).map(e).sum();
195    let mut out = vec![
196        LiftBand {
197            exposure: 0.0,
198            expected: 0.0,
199            actual: 0.0,
200        };
201        bands
202    ];
203    let mut cum = 0.0;
204    for &i in &order {
205        // Band by the exposure midpoint of the row.
206        let mid = (cum + 0.5 * e(i)) / total;
207        cum += e(i);
208        let b = ((mid * bands as f64) as usize).min(bands - 1);
209        out[b].exposure += e(i);
210        out[b].expected += pred[i];
211        out[b].actual += y[i];
212    }
213    Ok(out)
214}
215
216/// Continuous ranked probability score of `draws` (equally likely) for the
217/// outcome `y`: `E|X - y| - E|X - X'| / 2`, lower is better. Exact for the
218/// empirical distribution of the draws, in `O(m log m)`.
219///
220/// ```
221/// use prospicio_models::metrics::crps;
222///
223/// // One draw: the absolute error.
224/// assert_eq!(crps(&[3.0], 1.0).unwrap(), 2.0);
225/// ```
226pub fn crps(draws: &[f64], y: f64) -> Result<f64> {
227    if draws.is_empty() {
228        return Err(Error::InvalidParameter {
229            name: "draws",
230            value: 0.0,
231            reason: "must not be empty",
232        });
233    }
234    let m = draws.len() as f64;
235    let mut sorted = draws.to_vec();
236    sorted.sort_by(f64::total_cmp);
237    let abs_err: f64 = sorted.iter().map(|x| (x - y).abs()).sum::<f64>() / m;
238    // Σ_{i<j} (x_j - x_i) = Σ_i x_(i) (2i - m + 1), 0-based.
239    let spread: f64 = sorted
240        .iter()
241        .enumerate()
242        .map(|(i, x)| x * (2.0 * i as f64 - m + 1.0))
243        .sum::<f64>();
244    Ok(abs_err - spread / (m * m))
245}
246
247/// Share of outcomes inside their intervals `[lo, hi]`.
248pub fn coverage(y: &[f64], lo: &[f64], hi: &[f64]) -> Result<f64> {
249    same_length(y.len(), lo.len(), "lo")?;
250    same_length(y.len(), hi.len(), "hi")?;
251    let inside = (0..y.len())
252        .filter(|&i| lo[i] <= y[i] && y[i] <= hi[i])
253        .count();
254    Ok(inside as f64 / y.len() as f64)
255}
256
257/// Mean log score, `-(1/n) Σ log f(yᵢ)`, of each outcome under its
258/// predictive distribution: the family with mean `μᵢ`, dispersion `φ` and
259/// weight `wᵢ` ([`Family::log_density`]). Lower is better; it is the
260/// proper score that rewards a sharp and calibrated density, where CRPS
261/// works from draws alone.
262///
263/// ```
264/// use prospicio_models::{Family, metrics};
265///
266/// // Poisson(1) at y = 0: -log e^-1 = 1.
267/// assert!((metrics::log_score(Family::Poisson, &[0.0], &[1.0], 1.0, None).unwrap() - 1.0).abs() < 1e-15);
268/// ```
269pub fn log_score(
270    family: Family,
271    y: &[f64],
272    mu: &[f64],
273    dispersion: f64,
274    weights: Option<&[f64]>,
275) -> Result<f64> {
276    same_length(y.len(), mu.len(), "mu")?;
277    if let Some(w) = weights {
278        same_length(y.len(), w.len(), "weights")?;
279    }
280    let mut total = 0.0;
281    for i in 0..y.len() {
282        let w = weights.map_or(1.0, |w| w[i]);
283        total -= family.log_density(y[i], mu[i], dispersion, w)?;
284    }
285    Ok(total / y.len() as f64)
286}
287
288/// Probability integral transform of each outcome under its predictive
289/// distribution: `F(yᵢ)`, uniform on `(0, 1)` when the model is
290/// calibrated. Where the distribution has an atom (counts, a Tweedie's
291/// zero) the PIT is randomized, `F(y⁻) + u (F(y) - F(y⁻))` with `u` drawn
292/// from stream 0 of `seed` in row order (Czado, Gneiting and Held, 2009),
293/// so it is still uniform under the model. Check it with
294/// [`pit_histogram`] or [`ks_uniform`].
295pub fn pit(
296    family: Family,
297    y: &[f64],
298    mu: &[f64],
299    dispersion: f64,
300    weights: Option<&[f64]>,
301    seed: u64,
302) -> Result<Vec<f64>> {
303    same_length(y.len(), mu.len(), "mu")?;
304    if let Some(w) = weights {
305        same_length(y.len(), w.len(), "weights")?;
306    }
307    let mut rng = StreamRng::new(seed, 0);
308    (0..y.len())
309        .map(|i| {
310            let w = weights.map_or(1.0, |w| w[i]);
311            let (lo, hi) = family.cdf_bounds(y[i], mu[i], dispersion, w)?;
312            Ok(if hi > lo {
313                lo + rng.next_open01() * (hi - lo)
314            } else {
315                hi
316            })
317        })
318        .collect()
319}
320
321/// Randomized quantile residuals (Dunn and Smyth, 1996): `Φ⁻¹` of the
322/// randomized [`pit`] at the same `seed`, standard normal when the model is
323/// right. A continuous family's are exact, `Φ⁻¹(F(yᵢ))`; a count's, and the
324/// Tweedie's at zero, are drawn between `Φ⁻¹(F(y⁻))` and `Φ⁻¹(F(y))`, as
325/// R's `statmod::qresiduals`. Above the median the draw is made on the
326/// upper tail ([`Family::upper_tail_bounds`]) with the same uniform, so a
327/// residual far in the upper tail keeps its digits where `F(y)` rounds
328/// to 1 (`statmod`'s are infinite there).
329///
330/// ```
331/// use prospicio_models::{metrics::quantile_residuals, Family};
332///
333/// // A Gaussian's is the standardized residual (y − μ) / √(φ / w).
334/// let r = quantile_residuals(Family::Gaussian, &[3.0], &[1.0], 4.0, None, 0).unwrap();
335/// assert!((r[0] - 1.0).abs() < 1e-12);
336/// ```
337pub fn quantile_residuals(
338    family: Family,
339    y: &[f64],
340    mu: &[f64],
341    dispersion: f64,
342    weights: Option<&[f64]>,
343    seed: u64,
344) -> Result<Vec<f64>> {
345    same_length(y.len(), mu.len(), "mu")?;
346    if let Some(w) = weights {
347        same_length(y.len(), w.len(), "weights")?;
348    }
349    // The same stream as `pit`: a uniform only where the bounds differ.
350    let mut rng = StreamRng::new(seed, 0);
351    (0..y.len())
352        .map(|i| {
353            let w = weights.map_or(1.0, |w| w[i]);
354            let (lo, hi) = family.cdf_bounds(y[i], mu[i], dispersion, w)?;
355            let v = if hi > lo { rng.next_open01() } else { 1.0 };
356            Ok(if lo + hi > 1.0 {
357                let (s_lo, s_hi) = family.upper_tail_bounds(y[i], mu[i], dispersion, w)?;
358                -norm_quantile(s_lo - v * (s_lo - s_hi))
359            } else {
360                norm_quantile(lo + v * (hi - lo))
361            })
362        })
363        .collect()
364}
365
366/// The PIT of `y` under the empirical distribution of `draws` (one
367/// component of a predictive distribution), randomized over ties:
368/// `(#{x < y} + u #{x = y}) / m`.
369///
370/// ```
371/// use prospicio_models::metrics::pit_from_draws;
372///
373/// assert_eq!(pit_from_draws(&[1.0, 2.0, 3.0, 4.0], 2.5, 0.5).unwrap(), 0.5);
374/// ```
375pub fn pit_from_draws(draws: &[f64], y: f64, u: f64) -> Result<f64> {
376    if draws.is_empty() {
377        return Err(Error::InvalidParameter {
378            name: "draws",
379            value: 0.0,
380            reason: "must not be empty",
381        });
382    }
383    let below = draws.iter().filter(|&&x| x < y).count() as f64;
384    let ties = draws.iter().filter(|&&x| x == y).count() as f64;
385    Ok((below + u * ties) / draws.len() as f64)
386}
387
388/// Counts of PIT values in `bins` equal-width bins of `[0, 1]`: flat for a
389/// calibrated model, U-shaped when it is too sharp, humped when too wide.
390pub fn pit_histogram(pit: &[f64], bins: usize) -> Result<Vec<usize>> {
391    if bins == 0 {
392        return Err(Error::InvalidParameter {
393            name: "bins",
394            value: 0.0,
395            reason: "must be positive",
396        });
397    }
398    let mut counts = vec![0; bins];
399    for &p in pit {
400        let b = ((p * bins as f64) as usize).min(bins - 1);
401        counts[b] += 1;
402    }
403    Ok(counts)
404}
405
406/// Kolmogorov–Smirnov distance between the empirical distribution of
407/// `values` and the uniform on `[0, 1]`: `max |F̂(x) − x|`. Under
408/// uniformity it is about `1.36 / √n` or less 95% of the time.
409pub fn ks_uniform(values: &[f64]) -> Result<f64> {
410    if values.is_empty() {
411        return Err(Error::InvalidParameter {
412            name: "values",
413            value: 0.0,
414            reason: "must not be empty",
415        });
416    }
417    let mut v = values.to_vec();
418    v.sort_by(f64::total_cmp);
419    let n = v.len() as f64;
420    Ok(v.iter()
421        .enumerate()
422        .map(|(i, &x)| (x - i as f64 / n).max((i as f64 + 1.0) / n - x))
423        .fold(0.0, f64::max))
424}
425
426/// The normalized Gini: [`gini`] divided by the Gini of a perfect
427/// ranking, the rows sorted by their actual rate `y / exposure`. 1 for a
428/// model that ranks every row as its outcome does. For a 0/1 response with
429/// unit exposure, `AUROC = (1 + normalized Gini) / 2` (CAS Monograph 5,
430/// section 7.3.1, footnote 17).
431///
432/// ```
433/// use prospicio_models::metrics::normalized_gini;
434///
435/// let y = [0.0, 1.0, 0.0, 3.0];
436/// assert!((normalized_gini(&y, &y, None).unwrap() - 1.0).abs() < 1e-15);
437/// ```
438///
439/// Fails as [`gini`], or when the actual rates are all equal.
440pub fn normalized_gini(y: &[f64], pred: &[f64], exposure: Option<&[f64]>) -> Result<f64> {
441    let g = gini(y, pred, exposure)?;
442    let rate: Vec<f64> = match exposure {
443        Some(e) => y.iter().zip(e).map(|(y, e)| y / e).collect(),
444        None => y.to_vec(),
445    };
446    let best = gini(y, &rate, exposure)?;
447    if best == 0.0 {
448        return Err(Error::InvalidParameter {
449            name: "y",
450            value: best,
451            reason: "the actual rates must not all be equal",
452        });
453    }
454    Ok(g / best)
455}
456
457/// One band of a [`double_lift`] chart.
458#[derive(Debug, Clone, PartialEq)]
459pub struct DoubleLiftBand {
460    /// Total exposure in the band.
461    pub exposure: f64,
462    /// Total actual loss.
463    pub actual: f64,
464    /// Total loss predicted by the first model.
465    pub model_a: f64,
466    /// Total loss predicted by the second model.
467    pub model_b: f64,
468    /// The band's actual loss per exposure over the overall average.
469    pub actual_relativity: f64,
470    /// The first model's average in the band over its overall average.
471    pub a_relativity: f64,
472    /// The second model's average in the band over its overall average.
473    pub b_relativity: f64,
474}
475
476/// The double lift chart of two models (CAS Monograph 5, section 7.2.2):
477/// rows sorted by the ratio of their predictions `pred_a / pred_b`, cut
478/// into `bands` groups of about equal exposure, each with the actual and
479/// both models' totals and their averages relative to the overall averages
480/// (each model against its own, so a difference in overall level does not
481/// show). The model whose relativities follow the actual's better is the
482/// better one where the two disagree.
483///
484/// Fails when the lengths differ, `bands` is 0, a prediction of the second
485/// model is not positive, or a total is 0.
486pub fn double_lift(
487    y: &[f64],
488    pred_a: &[f64],
489    pred_b: &[f64],
490    exposure: Option<&[f64]>,
491    bands: usize,
492) -> Result<Vec<DoubleLiftBand>> {
493    same_length(y.len(), pred_a.len(), "pred_a")?;
494    same_length(y.len(), pred_b.len(), "pred_b")?;
495    if let Some(&bad) = pred_b.iter().find(|&&p| p.is_nan() || p <= 0.0) {
496        return Err(Error::InvalidParameter {
497            name: "pred_b",
498            value: bad,
499            reason: "must be positive",
500        });
501    }
502    let ratio: Vec<f64> = pred_a.iter().zip(pred_b).map(|(a, b)| a / b).collect();
503    let groups = banded(&ratio, exposure, y.len(), bands)?;
504    let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
505    let total = |v: &dyn Fn(usize) -> f64| (0..y.len()).map(v).sum::<f64>();
506    let total_e = total(&e);
507    let (avg_y, avg_a, avg_b) = (
508        total(&|i| y[i]) / total_e,
509        total(&|i| pred_a[i]) / total_e,
510        total(&|i| pred_b[i]) / total_e,
511    );
512    if avg_y == 0.0 || avg_a == 0.0 {
513        return Err(Error::InvalidParameter {
514            name: "y",
515            value: avg_y,
516            reason: "the actual and predicted totals must not be 0",
517        });
518    }
519    Ok(groups
520        .iter()
521        .map(|rows| {
522            let sum = |v: &dyn Fn(usize) -> f64| rows.iter().map(|&i| v(i)).sum::<f64>();
523            let (ex, actual, a, b) = (
524                sum(&e),
525                sum(&|i| y[i]),
526                sum(&|i| pred_a[i]),
527                sum(&|i| pred_b[i]),
528            );
529            DoubleLiftBand {
530                exposure: ex,
531                actual,
532                model_a: a,
533                model_b: b,
534                actual_relativity: actual / ex / avg_y,
535                a_relativity: a / ex / avg_a,
536                b_relativity: b / ex / avg_b,
537            }
538        })
539        .collect())
540}
541
542/// One band of a [`loss_ratio_chart`].
543#[derive(Debug, Clone, PartialEq)]
544pub struct LossRatioBand {
545    /// Total exposure in the band.
546    pub exposure: f64,
547    /// Total current premium.
548    pub premium: f64,
549    /// Total predicted loss.
550    pub expected: f64,
551    /// Total actual loss.
552    pub actual: f64,
553}
554
555impl LossRatioBand {
556    /// `actual / premium`, the band's actual loss ratio.
557    pub fn loss_ratio(&self) -> f64 {
558        self.actual / self.premium
559    }
560
561    /// `expected / premium`, the band's predicted loss ratio.
562    pub fn expected_loss_ratio(&self) -> f64 {
563        self.expected / self.premium
564    }
565}
566
567/// The loss ratio chart (CAS Monograph 5, section 7.2.3): rows sorted by
568/// their predicted loss ratio `pred / premium` against the current
569/// premium, cut into `bands` groups of about equal exposure, each with its
570/// actual loss ratio. A model that finds what the current rates miss shows
571/// actual loss ratios rising across the bands.
572///
573/// Fails when the lengths differ, `bands` is 0 or a premium is not
574/// positive.
575pub fn loss_ratio_chart(
576    y: &[f64],
577    pred: &[f64],
578    premium: &[f64],
579    exposure: Option<&[f64]>,
580    bands: usize,
581) -> Result<Vec<LossRatioBand>> {
582    same_length(y.len(), pred.len(), "pred")?;
583    same_length(y.len(), premium.len(), "premium")?;
584    if let Some(&bad) = premium.iter().find(|&&p| p.is_nan() || p <= 0.0) {
585        return Err(Error::InvalidParameter {
586            name: "premium",
587            value: bad,
588            reason: "must be positive",
589        });
590    }
591    let ratio: Vec<f64> = pred.iter().zip(premium).map(|(p, q)| p / q).collect();
592    let groups = banded(&ratio, exposure, y.len(), bands)?;
593    let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
594    Ok(groups
595        .iter()
596        .map(|rows| LossRatioBand {
597            exposure: rows.iter().map(|&i| e(i)).sum(),
598            premium: rows.iter().map(|&i| premium[i]).sum(),
599            expected: rows.iter().map(|&i| pred[i]).sum(),
600            actual: rows.iter().map(|&i| y[i]).sum(),
601        })
602        .collect())
603}
604
605/// Row indices sorted by `key` and cut into `bands` groups of about equal
606/// exposure, each row by its exposure midpoint, as [`lift`].
607fn banded(
608    key: &[f64],
609    exposure: Option<&[f64]>,
610    n: usize,
611    bands: usize,
612) -> Result<Vec<Vec<usize>>> {
613    if let Some(e) = exposure {
614        same_length(n, e.len(), "exposure")?;
615    }
616    if bands == 0 {
617        return Err(Error::InvalidParameter {
618            name: "bands",
619            value: 0.0,
620            reason: "must be positive",
621        });
622    }
623    let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
624    let mut order: Vec<usize> = (0..n).collect();
625    order.sort_by(|&a, &b| key[a].total_cmp(&key[b]));
626    let total: f64 = (0..n).map(e).sum();
627    let mut out = vec![Vec::new(); bands];
628    let mut cum = 0.0;
629    for &i in &order {
630        let mid = (cum + 0.5 * e(i)) / total;
631        cum += e(i);
632        out[((mid * bands as f64) as usize).min(bands - 1)].push(i);
633    }
634    Ok(out)
635}
636
637/// A receiver operating characteristic curve, from [`roc`].
638#[derive(Debug, Clone, PartialEq)]
639pub struct Roc {
640    /// The thresholds, descending: a row is predicted positive when its
641    /// score is at least the threshold. The first is `+∞` (nothing
642    /// positive).
643    pub thresholds: Vec<f64>,
644    /// False positive rate at each threshold.
645    pub false_positive_rate: Vec<f64>,
646    /// True positive rate (sensitivity) at each threshold.
647    pub true_positive_rate: Vec<f64>,
648    /// The area under the curve, by the trapezoid rule (ties in the score
649    /// count half).
650    pub auc: f64,
651}
652
653/// The ROC curve of a binary model (CAS Monograph 5, section 7.3.1): the
654/// true and false positive rates at every distinct score as threshold, and
655/// the area under the curve. `y` holds 0 and 1; `weights` default to 1.
656///
657/// ```
658/// use prospicio_models::metrics::roc;
659///
660/// let r = roc(&[0.0, 0.0, 1.0, 1.0], &[0.1, 0.4, 0.35, 0.8], None).unwrap();
661/// assert!((r.auc - 0.75).abs() < 1e-15);
662/// ```
663///
664/// Fails when the lengths differ, a response is not 0 or 1, or either
665/// class has no weight.
666pub fn roc(y: &[f64], score: &[f64], weights: Option<&[f64]>) -> Result<Roc> {
667    same_length(y.len(), score.len(), "score")?;
668    if let Some(w) = weights {
669        same_length(y.len(), w.len(), "weights")?;
670    }
671    if let Some(&bad) = y.iter().find(|&&v| v != 0.0 && v != 1.0) {
672        return Err(Error::InvalidParameter {
673            name: "y",
674            value: bad,
675            reason: "must be 0 or 1",
676        });
677    }
678    let w = |i: usize| weights.map_or(1.0, |w| w[i]);
679    let positives: f64 = (0..y.len()).filter(|&i| y[i] == 1.0).map(w).sum();
680    let negatives: f64 = (0..y.len()).filter(|&i| y[i] == 0.0).map(w).sum();
681    if positives <= 0.0 || negatives <= 0.0 {
682        return Err(Error::InvalidParameter {
683            name: "y",
684            value: positives,
685            reason: "needs weight in both classes",
686        });
687    }
688    let mut order: Vec<usize> = (0..y.len()).collect();
689    order.sort_by(|&a, &b| score[b].total_cmp(&score[a]));
690    let (mut thresholds, mut fpr, mut tpr) = (vec![f64::INFINITY], vec![0.0], vec![0.0]);
691    let (mut tp, mut fp, mut auc) = (0.0, 0.0, 0.0);
692    let mut i = 0;
693    while i < order.len() {
694        let s = score[order[i]];
695        let (x0, y0) = (fp / negatives, tp / positives);
696        while i < order.len() && score[order[i]] == s {
697            let k = order[i];
698            if y[k] == 1.0 {
699                tp += w(k);
700            } else {
701                fp += w(k);
702            }
703            i += 1;
704        }
705        let (x1, y1) = (fp / negatives, tp / positives);
706        auc += 0.5 * (x1 - x0) * (y0 + y1);
707        thresholds.push(s);
708        fpr.push(x1);
709        tpr.push(y1);
710    }
711    Ok(Roc {
712        thresholds,
713        false_positive_rate: fpr,
714        true_positive_rate: tpr,
715        auc,
716    })
717}
718
719/// Weighted counts of a binary model's predictions at a threshold, from
720/// [`confusion_matrix`].
721#[derive(Debug, Clone, Copy, PartialEq)]
722pub struct Confusion {
723    pub true_positive: f64,
724    pub false_positive: f64,
725    pub true_negative: f64,
726    pub false_negative: f64,
727}
728
729impl Confusion {
730    /// `TP / (TP + FN)`.
731    pub fn sensitivity(&self) -> f64 {
732        self.true_positive / (self.true_positive + self.false_negative)
733    }
734
735    /// `TN / (TN + FP)`.
736    pub fn specificity(&self) -> f64 {
737        self.true_negative / (self.true_negative + self.false_positive)
738    }
739}
740
741/// The confusion matrix of a binary model at `threshold`: a row is
742/// predicted positive when its score is at least the threshold. `y` holds
743/// 0 and 1; `weights` default to 1.
744///
745/// Fails when the lengths differ or a response is not 0 or 1.
746pub fn confusion_matrix(
747    y: &[f64],
748    score: &[f64],
749    threshold: f64,
750    weights: Option<&[f64]>,
751) -> Result<Confusion> {
752    same_length(y.len(), score.len(), "score")?;
753    if let Some(w) = weights {
754        same_length(y.len(), w.len(), "weights")?;
755    }
756    let mut c = Confusion {
757        true_positive: 0.0,
758        false_positive: 0.0,
759        true_negative: 0.0,
760        false_negative: 0.0,
761    };
762    for (i, (&y, &s)) in y.iter().zip(score).enumerate() {
763        let w = weights.map_or(1.0, |w| w[i]);
764        let cell = match (y, s >= threshold) {
765            (1.0, true) => &mut c.true_positive,
766            (0.0, true) => &mut c.false_positive,
767            (0.0, false) => &mut c.true_negative,
768            (1.0, false) => &mut c.false_negative,
769            _ => {
770                return Err(Error::InvalidParameter {
771                    name: "y",
772                    value: y,
773                    reason: "must be 0 or 1",
774                });
775            }
776        };
777        *cell += w;
778    }
779    Ok(c)
780}
781
782/// Variance inflation factors of a design's columns: `1 / (1 − R²_j)`, with
783/// `R²_j` that of column `j` regressed on the others and an intercept
784/// (CAS Monograph 5, section 2.9; R `car::vif` for single-column terms).
785/// Computed as the diagonal of the inverse of the columns' correlation
786/// matrix. A constant column (the intercept) gets `NaN`.
787///
788/// ```
789/// use prospicio_models::Design;
790/// use prospicio_models::metrics::vif;
791///
792/// let x = vec![1.0, 2.0, 3.0, 4.0];
793/// let z = vec![1.0, -1.0, -1.0, 1.0]; // uncorrelated with x
794/// let d = Design::new(
795///     vec!["(Intercept)".into(), "x".into(), "z".into()],
796///     vec![vec![1.0; 4], x, z],
797/// )
798/// .unwrap();
799/// let v = vif(&d).unwrap();
800/// assert!(v[0].is_nan());
801/// assert!((v[1] - 1.0).abs() < 1e-12 && (v[2] - 1.0).abs() < 1e-12);
802/// ```
803///
804/// Fails when the non-constant columns are collinear.
805pub fn vif(design: &Design) -> Result<Vec<f64>> {
806    let n = design.n_rows() as f64;
807    let centred: Vec<Option<Vec<f64>>> = (0..design.n_cols())
808        .map(|j| {
809            let x = design.column(j);
810            let mean = x.iter().sum::<f64>() / n;
811            let c: Vec<f64> = x.iter().map(|v| v - mean).collect();
812            let ss: f64 = c.iter().map(|v| v * v).sum();
813            (ss > 0.0).then(|| c.iter().map(|v| v / ss.sqrt()).collect())
814        })
815        .collect();
816    let kept: Vec<&Vec<f64>> = centred.iter().flatten().collect();
817    let k = kept.len();
818    let mut corr = vec![0.0; k * k];
819    for a in 0..k {
820        for b in 0..=a {
821            let r: f64 = kept[a].iter().zip(kept[b]).map(|(x, y)| x * y).sum();
822            corr[a * k + b] = r;
823            corr[b * k + a] = r;
824        }
825    }
826    let l = prospicio_math::linalg::cholesky(&corr, k).ok_or(Error::InvalidParameter {
827        name: "design",
828        value: k as f64,
829        reason: "has collinear columns",
830    })?;
831    let inverse = prospicio_math::linalg::cholesky_inverse(&l, k);
832    let mut next = 0;
833    Ok(centred
834        .iter()
835        .map(|c| match c {
836            Some(_) => {
837                let v = inverse[next * k + next];
838                next += 1;
839                v
840            }
841            None => f64::NAN,
842        })
843        .collect())
844}
845
846fn same_length(n: usize, m: usize, name: &'static str) -> Result<()> {
847    if n == m {
848        Ok(())
849    } else {
850        Err(Error::InvalidParameter {
851            name,
852            value: m as f64,
853            reason: "must have one value per observation",
854        })
855    }
856}
857
858#[cfg(test)]
859mod tests {
860
861    use super::*;
862
863    #[test]
864    fn quantile_residuals_are_standard_normal_under_the_model() {
865        // Counts and a Tweedie with many zeros, drawn from the model.
866        let mut rng = StreamRng::new(3, 0);
867        let n = 4000;
868        for family in [
869            Family::Poisson,
870            Family::NegativeBinomial { theta: 2.0 },
871            Family::Tweedie { power: 1.5 },
872        ] {
873            let mu: Vec<f64> = (0..n).map(|i| 0.3 + (i % 7) as f64 * 0.4).collect();
874            let phi = if matches!(family, Family::Tweedie { .. }) {
875                1.3
876            } else {
877                1.0
878            };
879            let y: Vec<f64> = mu
880                .iter()
881                .map(|&m| family.draw(m, phi, 1.0, rng.next_open01()).unwrap())
882                .collect();
883            let r = quantile_residuals(family, &y, &mu, phi, None, 11).unwrap();
884            let mean = r.iter().sum::<f64>() / n as f64;
885            let var = r.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
886            assert!(
887                mean.abs() < 0.05 && (var - 1.0).abs() < 0.07,
888                "{family:?}: {mean} {var}"
889            );
890            // `Φ⁻¹` of the PIT at the same seed, whose uniformity
891            // `pit_is_uniform_under_the_model` checks.
892            let p = pit(family, &y, &mu, phi, None, 11).unwrap();
893            for (r, p) in r.iter().zip(&p) {
894                assert!((prospicio_math::special::norm_cdf(*r) - p).abs() < 1e-12);
895            }
896        }
897    }
898
899    /// Far in a count's upper tail, where `F(y)` rounds to 1, the residual
900    /// is drawn between the normal scores of `P(Y ≥ y)` and `P(Y > y)`.
901    #[test]
902    fn quantile_residuals_keep_their_digits_in_the_upper_tail() {
903        use prospicio_math::special::ln_gamma;
904        // Poisson(2) at 40 and 41: P(Y ≥ k) ≈ pmf(k) / (1 − 2 / (k + 1)).
905        let pmf = |k: f64| (k * 2f64.ln() - 2.0 - ln_gamma(k + 1.0)).exp();
906        let r = quantile_residuals(Family::Poisson, &[40.0], &[2.0], 1.0, None, 5).unwrap()[0];
907        let at = pmf(40.0) + pmf(41.0) / (1.0 - 2.0 / 42.0);
908        let above = pmf(41.0) / (1.0 - 2.0 / 42.0);
909        assert!(r.is_finite() && r > 12.0);
910        assert!(r >= -norm_quantile(at) - 1e-3 && r <= -norm_quantile(above) + 1e-3);
911    }
912
913    /// Responses drawn from each family's own predictive distribution give
914    /// uniform PITs; a model with the wrong mean does not.
915    #[test]
916    fn pit_is_uniform_under_the_model() {
917        let families = [
918            (Family::Gaussian, 2.0),
919            (Family::Poisson, 1.0),
920            (Family::Poisson, 2.5),
921            (Family::Binomial, 1.0),
922            (Family::NegativeBinomial { theta: 1.5 }, 1.0),
923            (Family::Gamma, 0.4),
924            (Family::InverseGaussian, 0.3),
925            (Family::Tweedie { power: 1.5 }, 2.0),
926        ];
927        let n = 4000;
928        for (k, &(family, phi)) in families.iter().enumerate() {
929            let mut rng = StreamRng::new(9, k as u64);
930            let mu: Vec<f64> = (0..n)
931                .map(|i| match family {
932                    Family::Binomial => 0.2 + 0.6 * (i % 7) as f64 / 7.0,
933                    _ => 0.5 + (i % 5) as f64,
934                })
935                .collect();
936            let w: Vec<f64> = (0..n).map(|i| 1.0 + (i % 3) as f64).collect();
937            let y: Vec<f64> = (0..n)
938                .map(|i| family.draw(mu[i], phi, w[i], rng.next_open01()).unwrap())
939                .collect();
940            let p = pit(family, &y, &mu, phi, Some(&w), 1).unwrap();
941            let ks = ks_uniform(&p).unwrap();
942            assert!(ks < 1.63 / (n as f64).sqrt(), "{family:?}: ks {ks}");
943            let off: Vec<f64> = mu
944                .iter()
945                .map(|m| match family {
946                    Family::Binomial => m * 0.7,
947                    _ => m * 1.5,
948                })
949                .collect();
950            let bad = ks_uniform(&pit(family, &y, &off, phi, Some(&w), 1).unwrap()).unwrap();
951            assert!(bad > 3.0 / (n as f64).sqrt(), "{family:?}: misfit ks {bad}");
952            // The true means score better than the wrong ones.
953            let good = log_score(family, &y, &mu, phi, Some(&w)).unwrap();
954            let worse = log_score(family, &y, &off, phi, Some(&w)).unwrap();
955            assert!(good < worse, "{family:?}");
956        }
957    }
958
959    #[test]
960    fn log_density_is_the_log_likelihood_where_they_agree() {
961        for family in [
962            Family::Gaussian,
963            Family::Gamma,
964            Family::InverseGaussian,
965            Family::Tweedie { power: 1.4 },
966        ] {
967            let a = family.log_density(1.7, 2.0, 0.8, 1.5).unwrap();
968            let b = family.log_likelihood(1.7, 2.0, 1.5, 0.8);
969            assert!((a - b).abs() < 1e-12, "{family:?}");
970        }
971        for (family, y) in [
972            (Family::Poisson, 3.0),
973            (Family::NegativeBinomial { theta: 2.0 }, 3.0),
974        ] {
975            let a = family.log_density(y, 2.0, 1.0, 1.0).unwrap();
976            let b = family.log_likelihood(y, 2.0, 1.0, 1.0);
977            assert!((a - b).abs() < 1e-12, "{family:?}");
978        }
979        assert_eq!(
980            Family::Poisson.log_density(1.5, 2.0, 1.0, 1.0).unwrap(),
981            f64::NEG_INFINITY
982        );
983        assert_eq!(
984            pit_histogram(&[0.05, 0.15, 0.95, 1.0], 2).unwrap(),
985            vec![2, 2]
986        );
987    }
988
989    #[test]
990    fn crps_matches_the_pairwise_definition() {
991        let x: [f64; 6] = [0.3, -1.2, 2.5, 0.3, 4.0, 1.1];
992        let y = 0.7;
993        let m = x.len() as f64;
994        let a: f64 = x.iter().map(|v| (v - y).abs()).sum::<f64>() / m;
995        let b: f64 = x
996            .iter()
997            .flat_map(|u| x.iter().map(move |v| (u - v).abs()))
998            .sum::<f64>()
999            / (m * m);
1000        assert!((crps(&x, y).unwrap() - (a - 0.5 * b)).abs() < 1e-14);
1001    }
1002
1003    #[test]
1004    fn gini_with_exposure_and_lift() {
1005        // Rates 0, 1, 2 on exposures 1, 2, 1 and a model that ranks them.
1006        let y = [0.0, 2.0, 2.0];
1007        let pred = [0.1, 1.8, 2.1];
1008        let e = [1.0, 2.0, 1.0];
1009        let g = gini(&y, &pred, Some(&e)).unwrap();
1010        // Lorenz points: (0, 0), (1/4, 0), (3/4, 1/2), (1, 1).
1011        let area = 0.5 * 0.5 * 0.5 + 0.5 * 0.25 * 1.5;
1012        assert!((g - (1.0 - 2.0 * area)).abs() < 1e-15);
1013        let bands = lift(&y, &pred, Some(&e), 2).unwrap();
1014        assert_eq!(bands[0].exposure + bands[1].exposure, 4.0);
1015        assert!(bands[1].actual / bands[1].exposure >= bands[0].actual / bands[0].exposure);
1016    }
1017
1018    #[test]
1019    fn deviance_scores_and_coverage() {
1020        let y = [1.0, 0.0, 3.0];
1021        let mu = [1.0, 0.5, 2.0];
1022        let d = deviance(Family::Poisson, &y, &mu, None).unwrap();
1023        let want = 2.0 * (0.5 + (3.0 * 1.5f64.ln() - 1.0));
1024        assert!((d - want).abs() < 1e-14);
1025        assert!(
1026            (mean_deviance(Family::Poisson, &y, &mu, None).unwrap() - want / 3.0).abs() < 1e-15
1027        );
1028        assert_eq!(
1029            coverage(&y, &[0.0, 0.0, 0.0], &[2.0, 2.0, 2.0]).unwrap(),
1030            2.0 / 3.0
1031        );
1032        assert!(rmse(&y, &[1.0]).is_err());
1033    }
1034
1035    #[test]
1036    fn auc_is_the_mann_whitney_probability_and_tracks_the_gini() {
1037        let n = 200;
1038        let score: Vec<f64> = (0..n).map(|i| ((i * 37) % 101) as f64 / 101.0).collect();
1039        let y: Vec<f64> = (0..n)
1040            .map(|i| {
1041                if score[i] + ((i * 17) % 13) as f64 / 26.0 > 0.8 {
1042                    1.0
1043                } else {
1044                    0.0
1045                }
1046            })
1047            .collect();
1048        let r = roc(&y, &score, None).unwrap();
1049        // P(score of a positive > score of a negative), ties counting half.
1050        let (mut wins, mut pairs) = (0.0, 0.0);
1051        for i in 0..n {
1052            for j in 0..n {
1053                if y[i] == 1.0 && y[j] == 0.0 {
1054                    pairs += 1.0;
1055                    wins += if score[i] > score[j] {
1056                        1.0
1057                    } else if score[i] == score[j] {
1058                        0.5
1059                    } else {
1060                        0.0
1061                    };
1062                }
1063            }
1064        }
1065        assert!((r.auc - wins / pairs).abs() < 1e-12);
1066        let ng = normalized_gini(&y, &score, None).unwrap();
1067        assert!((r.auc - 0.5 * (1.0 + ng)).abs() < 1e-12, "{} {}", r.auc, ng);
1068        assert_eq!(r.thresholds[0], f64::INFINITY);
1069        assert_eq!(*r.true_positive_rate.last().unwrap(), 1.0);
1070        let c = confusion_matrix(&y, &score, 0.5, None).unwrap();
1071        let total = c.true_positive + c.false_positive + c.true_negative + c.false_negative;
1072        assert_eq!(total, n as f64);
1073        assert!(roc(&[0.0, 2.0], &[0.1, 0.2], None).is_err());
1074    }
1075
1076    #[test]
1077    fn double_lift_and_loss_ratio_bands_add_up() {
1078        let n = 100;
1079        let e: Vec<f64> = (0..n).map(|i| 0.5 + (i % 3) as f64 * 0.25).collect();
1080        let a: Vec<f64> = (0..n)
1081            .map(|i| e[i] * (1.0 + (i % 7) as f64 / 10.0))
1082            .collect();
1083        let b: Vec<f64> = (0..n)
1084            .map(|i| e[i] * (1.2 + (i % 5) as f64 / 10.0))
1085            .collect();
1086        let y: Vec<f64> = (0..n)
1087            .map(|i| a[i] * (0.8 + (i % 4) as f64 / 10.0))
1088            .collect();
1089        let bands = double_lift(&y, &a, &b, Some(&e), 5).unwrap();
1090        assert_eq!(bands.len(), 5);
1091        let total_e: f64 = e.iter().sum();
1092        // Exposure-weighted relativities average to 1.
1093        for rel in [
1094            bands
1095                .iter()
1096                .map(|b| b.exposure * b.actual_relativity)
1097                .sum::<f64>(),
1098            bands
1099                .iter()
1100                .map(|b| b.exposure * b.a_relativity)
1101                .sum::<f64>(),
1102            bands
1103                .iter()
1104                .map(|b| b.exposure * b.b_relativity)
1105                .sum::<f64>(),
1106        ] {
1107            assert!((rel / total_e - 1.0).abs() < 1e-12);
1108        }
1109        // Sorted by a / b: the first model's relativity to the second's rises.
1110        let r: Vec<f64> = bands.iter().map(|b| b.model_a / b.model_b).collect();
1111        assert!(r.windows(2).all(|w| w[0] <= w[1]));
1112        let lr = loss_ratio_chart(&y, &a, &b, Some(&e), 4).unwrap();
1113        let actual: f64 = lr.iter().map(|b| b.actual).sum();
1114        assert!((actual - y.iter().sum::<f64>()).abs() < 1e-9);
1115        let er: Vec<f64> = lr.iter().map(LossRatioBand::expected_loss_ratio).collect();
1116        assert!(er.windows(2).all(|w| w[0] <= w[1]));
1117        assert!(loss_ratio_chart(&y, &a, &vec![0.0; n], None, 4).is_err());
1118    }
1119}