Skip to main content

plotters_statistical/stats/
gain.rs

1//! Cumulative gain and lift curves for ranking/classification evaluation.
2
3use super::StatsError;
4
5/// One point on a cumulative gain / lift curve.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct GainPoint {
8    /// Fraction of the population targeted (ranked by descending score).
9    pub fraction: f64,
10    /// Cumulative gain: fraction of all positives captured so far, in `[0, 1]`.
11    pub gain: f64,
12    /// Lift: `gain / fraction` — how many times better than random targeting.
13    /// `1.0` at `fraction == 1.0`; the leading `fraction == 0` point carries
14    /// `f64::NAN` lift (undefined) and is expected to be skipped when plotting
15    /// lift.
16    pub lift: f64,
17}
18
19/// Compute the cumulative gain / lift curve from predicted `scores` and true
20/// binary `labels`. Points are ordered by descending score and always start at
21/// `fraction == 0, gain == 0`.
22///
23/// # Errors
24/// * [`StatsError::LengthMismatch`] if the inputs differ in length.
25/// * [`StatsError::EmptyInput`] if empty.
26/// * [`StatsError::NoPositiveLabels`] if there are no positives (gain undefined).
27pub fn gain_curve(scores: &[f64], labels: &[bool]) -> Result<Vec<GainPoint>, StatsError> {
28    if scores.len() != labels.len() {
29        return Err(StatsError::LengthMismatch {
30            scores: scores.len(),
31            labels: labels.len(),
32        });
33    }
34    if scores.is_empty() {
35        return Err(StatsError::EmptyInput);
36    }
37    let total_p = labels.iter().filter(|&&l| l).count();
38    if total_p == 0 {
39        return Err(StatsError::NoPositiveLabels);
40    }
41
42    let mut order: Vec<usize> = (0..scores.len()).collect();
43    order.sort_by(|&a, &b| {
44        scores[b]
45            .partial_cmp(&scores[a])
46            .unwrap_or(std::cmp::Ordering::Equal)
47    });
48
49    let n = scores.len() as f64;
50    let p = total_p as f64;
51    let mut points = Vec::with_capacity(order.len() + 1);
52    points.push(GainPoint {
53        fraction: 0.0,
54        gain: 0.0,
55        lift: f64::NAN,
56    });
57    let mut cum_pos = 0usize;
58    for (k, &i) in order.iter().enumerate() {
59        if labels[i] {
60            cum_pos += 1;
61        }
62        let fraction = (k + 1) as f64 / n;
63        let gain = cum_pos as f64 / p;
64        points.push(GainPoint {
65            fraction,
66            gain,
67            lift: gain / fraction,
68        });
69    }
70    Ok(points)
71}