1use prospicio_core::{Error, Result, StreamRng};
9use prospicio_math::special::norm_quantile;
10
11use crate::design::Design;
12use crate::family::Family;
13
14pub 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
34pub 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
46pub 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
59pub 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
66pub 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
100pub 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 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#[derive(Debug, Clone, PartialEq)]
154pub struct LiftBand {
155 pub exposure: f64,
157 pub expected: f64,
159 pub actual: f64,
161}
162
163impl LiftBand {
164 pub fn ratio(&self) -> f64 {
166 self.actual / self.expected
167 }
168}
169
170pub 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 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
216pub 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 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
247pub 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
257pub 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
288pub 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
321pub 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 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
366pub 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
388pub 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
406pub 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
426pub 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#[derive(Debug, Clone, PartialEq)]
459pub struct DoubleLiftBand {
460 pub exposure: f64,
462 pub actual: f64,
464 pub model_a: f64,
466 pub model_b: f64,
468 pub actual_relativity: f64,
470 pub a_relativity: f64,
472 pub b_relativity: f64,
474}
475
476pub 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#[derive(Debug, Clone, PartialEq)]
544pub struct LossRatioBand {
545 pub exposure: f64,
547 pub premium: f64,
549 pub expected: f64,
551 pub actual: f64,
553}
554
555impl LossRatioBand {
556 pub fn loss_ratio(&self) -> f64 {
558 self.actual / self.premium
559 }
560
561 pub fn expected_loss_ratio(&self) -> f64 {
563 self.expected / self.premium
564 }
565}
566
567pub 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
605fn 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#[derive(Debug, Clone, PartialEq)]
639pub struct Roc {
640 pub thresholds: Vec<f64>,
644 pub false_positive_rate: Vec<f64>,
646 pub true_positive_rate: Vec<f64>,
648 pub auc: f64,
651}
652
653pub 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#[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 pub fn sensitivity(&self) -> f64 {
732 self.true_positive / (self.true_positive + self.false_negative)
733 }
734
735 pub fn specificity(&self) -> f64 {
737 self.true_negative / (self.true_negative + self.false_positive)
738 }
739}
740
741pub 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
782pub 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 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 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 #[test]
902 fn quantile_residuals_keep_their_digits_in_the_upper_tail() {
903 use prospicio_math::special::ln_gamma;
904 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 #[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 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 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 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 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 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 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}