Skip to main content

plotters_statistical/stats/
mod.rs

1//! Pure statistical computation — **no `plotters` dependency**.
2//!
3//! Everything a chart type needs to know *what* to draw (quartiles, KDE curves,
4//! ROC/PR points, AUC) is computed here and unit-tested against hand-computed
5//! reference values, independently of any rendering. The [`series`](crate::series)
6//! layer consumes these outputs and turns them into draw calls.
7
8pub mod calibration;
9pub mod correlation;
10pub mod ecdf;
11pub mod gain;
12pub mod histogram;
13pub mod kde;
14pub mod normal;
15pub mod precision_recall;
16pub mod quartiles;
17pub mod roc;
18
19pub use calibration::{calibration_curve, CalibrationBin};
20pub use correlation::{correlation_matrix, pearson, rank, spearman, CorrelationMethod};
21pub use ecdf::{dkw_epsilon, ecdf, Ecdf as EcdfData};
22pub use gain::{gain_curve, GainPoint};
23pub use histogram::{histogram, BinRule, Histogram};
24pub use kde::{kde_curve, silverman_bandwidth, KdeCurve};
25pub use normal::norm_ppf;
26pub use precision_recall::{precision_recall_curve, PrCurve, PrPoint};
27pub use quartiles::{quartiles, Quartiles};
28pub use roc::{roc_curve, RocCurveData, RocPoint};
29
30use std::error::Error;
31use std::fmt;
32
33/// Errors returned by the statistics routines for inputs where a metric is
34/// undefined, rather than letting `NaN` propagate silently into a chart.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum StatsError {
37    /// The input sample was empty.
38    EmptyInput,
39    /// `scores` and `labels` had different lengths.
40    LengthMismatch {
41        /// Number of scores supplied.
42        scores: usize,
43        /// Number of labels supplied.
44        labels: usize,
45    },
46    /// The label vector contained no positive examples, so TPR / recall is
47    /// undefined (division by zero positives).
48    NoPositiveLabels,
49    /// The label vector contained no negative examples, so FPR is undefined
50    /// (division by zero negatives). Only relevant to ROC.
51    NoNegativeLabels,
52    /// A supplied bandwidth (or one derived from a degenerate sample) was not a
53    /// finite, strictly-positive number.
54    InvalidBandwidth,
55    /// A computation needing non-zero spread got a constant (zero-variance)
56    /// input — e.g. Pearson correlation of a constant column, or a histogram of
57    /// all-identical values.
58    ZeroVariance,
59}
60
61impl fmt::Display for StatsError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            StatsError::EmptyInput => write!(f, "input sample was empty"),
65            StatsError::LengthMismatch { scores, labels } => write!(
66                f,
67                "scores/labels length mismatch: {scores} scores vs {labels} labels"
68            ),
69            StatsError::NoPositiveLabels => {
70                write!(f, "no positive labels: TPR/recall is undefined")
71            }
72            StatsError::NoNegativeLabels => {
73                write!(f, "no negative labels: FPR is undefined")
74            }
75            StatsError::InvalidBandwidth => {
76                write!(f, "bandwidth must be a finite, strictly-positive number")
77            }
78            StatsError::ZeroVariance => {
79                write!(f, "input has zero variance (constant values)")
80            }
81        }
82    }
83}
84
85impl Error for StatsError {}
86
87/// Sort a copy of `data` ascending, dropping any non-finite values.
88///
89/// Shared by the quartile and KDE routines. NaNs cannot be ordered, so they are
90/// discarded rather than allowed to poison a comparison sort.
91pub(crate) fn sorted_finite(data: &[f64]) -> Vec<f64> {
92    let mut v: Vec<f64> = data.iter().copied().filter(|x| x.is_finite()).collect();
93    v.sort_by(|a, b| a.partial_cmp(b).expect("finite values are totally ordered"));
94    v
95}
96
97/// Linear-interpolation percentile (the "type 7" definition, as used by NumPy
98/// and R's default `quantile`) over a slice that is already sorted ascending.
99///
100/// `p` is a fraction in `[0, 1]`. Panics only if `sorted` is empty — callers
101/// guard against that and return [`StatsError::EmptyInput`] instead.
102pub(crate) fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
103    debug_assert!(!sorted.is_empty());
104    let n = sorted.len();
105    if n == 1 {
106        return sorted[0];
107    }
108    let rank = p.clamp(0.0, 1.0) * (n as f64 - 1.0);
109    let lo = rank.floor() as usize;
110    let hi = rank.ceil() as usize;
111    if lo == hi {
112        sorted[lo]
113    } else {
114        let frac = rank - lo as f64;
115        sorted[lo] * (1.0 - frac) + sorted[hi] * frac
116    }
117}