plotters-statistical 0.1.0

Statistical chart primitives (box, violin, ROC, PR, regularization-path, residual) as native plotters series
Documentation
//! Quartiles, IQR, and Tukey whisker/outlier bounds for box plots.
//!
//! **Quartile method:** quartiles are computed by linear interpolation between
//! order statistics — the "type 7" definition used by NumPy's
//! `percentile`/`quantile` and R's default `quantile`. Different statistics
//! packages use different quartile definitions that disagree by small amounts;
//! we fix type 7 and document it here because that disagreement is a classic
//! source of "my box looks slightly off" confusion.

use super::{percentile_sorted, sorted_finite, StatsError};

/// Five-number summary plus Tukey fences and the outliers of a sample.
#[derive(Debug, Clone, PartialEq)]
pub struct Quartiles {
    /// First quartile (25th percentile).
    pub q1: f64,
    /// Median (50th percentile).
    pub median: f64,
    /// Third quartile (75th percentile).
    pub q3: f64,
    /// Interquartile range, `q3 - q1`.
    pub iqr: f64,
    /// Lower whisker end: the smallest sample value `>= q1 - 1.5*IQR`.
    pub lower_whisker: f64,
    /// Upper whisker end: the largest sample value `<= q3 + 1.5*IQR`.
    pub upper_whisker: f64,
    /// Sample minimum (finite values only).
    pub min: f64,
    /// Sample maximum (finite values only).
    pub max: f64,
    /// Values falling outside the `1.5*IQR` Tukey fences, in ascending order.
    pub outliers: Vec<f64>,
}

impl Quartiles {
    /// The lower Tukey fence, `q1 - 1.5 * IQR`. Points below it are outliers.
    pub fn lower_fence(&self) -> f64 {
        self.q1 - 1.5 * self.iqr
    }

    /// The upper Tukey fence, `q3 + 1.5 * IQR`. Points above it are outliers.
    pub fn upper_fence(&self) -> f64 {
        self.q3 + 1.5 * self.iqr
    }
}

/// Compute [`Quartiles`] for `data` using the type-7 (linear-interpolation)
/// quartile definition and the standard `1.5 * IQR` Tukey rule for whiskers and
/// outliers.
///
/// Non-finite values (`NaN`, `±inf`) are ignored. Returns
/// [`StatsError::EmptyInput`] if no finite values remain.
///
/// # Edge cases
///
/// * A **single point** yields `q1 == median == q3`, zero IQR, coincident
///   whiskers, and no outliers.
/// * **All-identical** values yield zero IQR; with zero IQR the fences collapse
///   onto the value, so nothing is flagged as an outlier.
///
/// ```
/// # use plotters_statistical::stats::quartiles;
/// let q = quartiles(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
/// assert_eq!(q.median, 3.0);
/// assert_eq!(q.q1, 2.0);
/// assert_eq!(q.q3, 4.0);
/// ```
pub fn quartiles(data: &[f64]) -> Result<Quartiles, StatsError> {
    let sorted = sorted_finite(data);
    if sorted.is_empty() {
        return Err(StatsError::EmptyInput);
    }

    let q1 = percentile_sorted(&sorted, 0.25);
    let median = percentile_sorted(&sorted, 0.50);
    let q3 = percentile_sorted(&sorted, 0.75);
    let iqr = q3 - q1;

    let lower_fence = q1 - 1.5 * iqr;
    let upper_fence = q3 + 1.5 * iqr;

    // Whiskers extend to the most extreme sample value still inside the fences.
    let lower_whisker = sorted
        .iter()
        .copied()
        .find(|&x| x >= lower_fence)
        .unwrap_or(sorted[0]);
    let upper_whisker = sorted
        .iter()
        .rev()
        .copied()
        .find(|&x| x <= upper_fence)
        .unwrap_or(sorted[sorted.len() - 1]);

    let fences = lower_fence..=upper_fence;
    let outliers: Vec<f64> = sorted
        .iter()
        .copied()
        .filter(|x| !fences.contains(x))
        .collect();

    Ok(Quartiles {
        q1,
        median,
        q3,
        iqr,
        lower_whisker,
        upper_whisker,
        min: sorted[0],
        max: sorted[sorted.len() - 1],
        outliers,
    })
}