Skip to main content

plotters_statistical/stats/
quartiles.rs

1//! Quartiles, IQR, and Tukey whisker/outlier bounds for box plots.
2//!
3//! **Quartile method:** quartiles are computed by linear interpolation between
4//! order statistics — the "type 7" definition used by NumPy's
5//! `percentile`/`quantile` and R's default `quantile`. Different statistics
6//! packages use different quartile definitions that disagree by small amounts;
7//! we fix type 7 and document it here because that disagreement is a classic
8//! source of "my box looks slightly off" confusion.
9
10use super::{percentile_sorted, sorted_finite, StatsError};
11
12/// Five-number summary plus Tukey fences and the outliers of a sample.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Quartiles {
15    /// First quartile (25th percentile).
16    pub q1: f64,
17    /// Median (50th percentile).
18    pub median: f64,
19    /// Third quartile (75th percentile).
20    pub q3: f64,
21    /// Interquartile range, `q3 - q1`.
22    pub iqr: f64,
23    /// Lower whisker end: the smallest sample value `>= q1 - 1.5*IQR`.
24    pub lower_whisker: f64,
25    /// Upper whisker end: the largest sample value `<= q3 + 1.5*IQR`.
26    pub upper_whisker: f64,
27    /// Sample minimum (finite values only).
28    pub min: f64,
29    /// Sample maximum (finite values only).
30    pub max: f64,
31    /// Values falling outside the `1.5*IQR` Tukey fences, in ascending order.
32    pub outliers: Vec<f64>,
33}
34
35impl Quartiles {
36    /// The lower Tukey fence, `q1 - 1.5 * IQR`. Points below it are outliers.
37    pub fn lower_fence(&self) -> f64 {
38        self.q1 - 1.5 * self.iqr
39    }
40
41    /// The upper Tukey fence, `q3 + 1.5 * IQR`. Points above it are outliers.
42    pub fn upper_fence(&self) -> f64 {
43        self.q3 + 1.5 * self.iqr
44    }
45}
46
47/// Compute [`Quartiles`] for `data` using the type-7 (linear-interpolation)
48/// quartile definition and the standard `1.5 * IQR` Tukey rule for whiskers and
49/// outliers.
50///
51/// Non-finite values (`NaN`, `±inf`) are ignored. Returns
52/// [`StatsError::EmptyInput`] if no finite values remain.
53///
54/// # Edge cases
55///
56/// * A **single point** yields `q1 == median == q3`, zero IQR, coincident
57///   whiskers, and no outliers.
58/// * **All-identical** values yield zero IQR; with zero IQR the fences collapse
59///   onto the value, so nothing is flagged as an outlier.
60///
61/// ```
62/// # use plotters_statistical::stats::quartiles;
63/// let q = quartiles(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
64/// assert_eq!(q.median, 3.0);
65/// assert_eq!(q.q1, 2.0);
66/// assert_eq!(q.q3, 4.0);
67/// ```
68pub fn quartiles(data: &[f64]) -> Result<Quartiles, StatsError> {
69    let sorted = sorted_finite(data);
70    if sorted.is_empty() {
71        return Err(StatsError::EmptyInput);
72    }
73
74    let q1 = percentile_sorted(&sorted, 0.25);
75    let median = percentile_sorted(&sorted, 0.50);
76    let q3 = percentile_sorted(&sorted, 0.75);
77    let iqr = q3 - q1;
78
79    let lower_fence = q1 - 1.5 * iqr;
80    let upper_fence = q3 + 1.5 * iqr;
81
82    // Whiskers extend to the most extreme sample value still inside the fences.
83    let lower_whisker = sorted
84        .iter()
85        .copied()
86        .find(|&x| x >= lower_fence)
87        .unwrap_or(sorted[0]);
88    let upper_whisker = sorted
89        .iter()
90        .rev()
91        .copied()
92        .find(|&x| x <= upper_fence)
93        .unwrap_or(sorted[sorted.len() - 1]);
94
95    let fences = lower_fence..=upper_fence;
96    let outliers: Vec<f64> = sorted
97        .iter()
98        .copied()
99        .filter(|x| !fences.contains(x))
100        .collect();
101
102    Ok(Quartiles {
103        q1,
104        median,
105        q3,
106        iqr,
107        lower_whisker,
108        upper_whisker,
109        min: sorted[0],
110        max: sorted[sorted.len() - 1],
111        outliers,
112    })
113}