plotters-statistical 0.1.0

Statistical chart primitives (box, violin, ROC, PR, regularization-path, residual) as native plotters series
Documentation
//! Pure statistical computation — **no `plotters` dependency**.
//!
//! Everything a chart type needs to know *what* to draw (quartiles, KDE curves,
//! ROC/PR points, AUC) is computed here and unit-tested against hand-computed
//! reference values, independently of any rendering. The [`series`](crate::series)
//! layer consumes these outputs and turns them into draw calls.

pub mod calibration;
pub mod correlation;
pub mod ecdf;
pub mod gain;
pub mod histogram;
pub mod kde;
pub mod normal;
pub mod precision_recall;
pub mod quartiles;
pub mod roc;

pub use calibration::{calibration_curve, CalibrationBin};
pub use correlation::{correlation_matrix, pearson, rank, spearman, CorrelationMethod};
pub use ecdf::{dkw_epsilon, ecdf, Ecdf as EcdfData};
pub use gain::{gain_curve, GainPoint};
pub use histogram::{histogram, BinRule, Histogram};
pub use kde::{kde_curve, silverman_bandwidth, KdeCurve};
pub use normal::norm_ppf;
pub use precision_recall::{precision_recall_curve, PrCurve, PrPoint};
pub use quartiles::{quartiles, Quartiles};
pub use roc::{roc_curve, RocCurveData, RocPoint};

use std::error::Error;
use std::fmt;

/// Errors returned by the statistics routines for inputs where a metric is
/// undefined, rather than letting `NaN` propagate silently into a chart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatsError {
    /// The input sample was empty.
    EmptyInput,
    /// `scores` and `labels` had different lengths.
    LengthMismatch {
        /// Number of scores supplied.
        scores: usize,
        /// Number of labels supplied.
        labels: usize,
    },
    /// The label vector contained no positive examples, so TPR / recall is
    /// undefined (division by zero positives).
    NoPositiveLabels,
    /// The label vector contained no negative examples, so FPR is undefined
    /// (division by zero negatives). Only relevant to ROC.
    NoNegativeLabels,
    /// A supplied bandwidth (or one derived from a degenerate sample) was not a
    /// finite, strictly-positive number.
    InvalidBandwidth,
    /// A computation needing non-zero spread got a constant (zero-variance)
    /// input — e.g. Pearson correlation of a constant column, or a histogram of
    /// all-identical values.
    ZeroVariance,
}

impl fmt::Display for StatsError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            StatsError::EmptyInput => write!(f, "input sample was empty"),
            StatsError::LengthMismatch { scores, labels } => write!(
                f,
                "scores/labels length mismatch: {scores} scores vs {labels} labels"
            ),
            StatsError::NoPositiveLabels => {
                write!(f, "no positive labels: TPR/recall is undefined")
            }
            StatsError::NoNegativeLabels => {
                write!(f, "no negative labels: FPR is undefined")
            }
            StatsError::InvalidBandwidth => {
                write!(f, "bandwidth must be a finite, strictly-positive number")
            }
            StatsError::ZeroVariance => {
                write!(f, "input has zero variance (constant values)")
            }
        }
    }
}

impl Error for StatsError {}

/// Sort a copy of `data` ascending, dropping any non-finite values.
///
/// Shared by the quartile and KDE routines. NaNs cannot be ordered, so they are
/// discarded rather than allowed to poison a comparison sort.
pub(crate) fn sorted_finite(data: &[f64]) -> Vec<f64> {
    let mut v: Vec<f64> = data.iter().copied().filter(|x| x.is_finite()).collect();
    v.sort_by(|a, b| a.partial_cmp(b).expect("finite values are totally ordered"));
    v
}

/// Linear-interpolation percentile (the "type 7" definition, as used by NumPy
/// and R's default `quantile`) over a slice that is already sorted ascending.
///
/// `p` is a fraction in `[0, 1]`. Panics only if `sorted` is empty — callers
/// guard against that and return [`StatsError::EmptyInput`] instead.
pub(crate) fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
    debug_assert!(!sorted.is_empty());
    let n = sorted.len();
    if n == 1 {
        return sorted[0];
    }
    let rank = p.clamp(0.0, 1.0) * (n as f64 - 1.0);
    let lo = rank.floor() as usize;
    let hi = rank.ceil() as usize;
    if lo == hi {
        sorted[lo]
    } else {
        let frac = rank - lo as f64;
        sorted[lo] * (1.0 - frac) + sorted[hi] * frac
    }
}