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}