Skip to main content

ggplot_rs/stat/
qq.rs

1//! Quantile–quantile statistics: `stat_qq`, `stat_qq_line` (ggplot2) and
2//! `stat_qq_band` (qqplotr's pointwise / KS confidence envelopes) against a
3//! choice of theoretical distribution.
4//!
5//! Plotting positions are R's `ppoints(n)` (`a = 3/8` for `n ≤ 10`, else
6//! `1/2`); sample quantiles for the reference line use R's default type-7
7//! interpolation. All values are validated against R (`stats::qqnorm`,
8//! ggplot2 4.0 `stat_qq`/`stat_qq_line`, qqplotr 0.0.7 `stat_qq_band`) in
9//! `tests/qq_dist_r.rs`.
10
11use crate::aes::Aesthetic;
12use crate::data::{DataFrame, Value};
13use crate::scale::ScaleSet;
14
15use super::distribution as d;
16use super::Stat;
17
18/// R-compatible type-7 quantile interpolation (R's default `quantile()` method).
19fn quantile_type7(sorted: &[f64], p: f64) -> f64 {
20    let n = sorted.len();
21    if n == 0 {
22        return 0.0;
23    }
24    if n == 1 {
25        return sorted[0];
26    }
27    let h = (n - 1) as f64 * p;
28    let lo = h.floor() as usize;
29    let hi = (lo + 1).min(n - 1);
30    let frac = h - lo as f64;
31    sorted[lo] + frac * (sorted[hi] - sorted[lo])
32}
33
34/// R's `ppoints(n)`.
35pub fn ppoints(n: usize) -> Vec<f64> {
36    let a = if n <= 10 { 3.0 / 8.0 } else { 0.5 };
37    (0..n)
38        .map(|i| (i as f64 + 1.0 - a) / (n as f64 + 1.0 - 2.0 * a))
39        .collect()
40}
41
42/// Theoretical distribution of a QQ plot, with its parameters (ggplot2's
43/// `distribution` + `dparams`).
44#[derive(Clone, Copy, Debug, PartialEq)]
45#[non_exhaustive]
46pub enum QQDistribution {
47    /// Normal (`qnorm`), default `mean = 0, sd = 1`.
48    Normal { mean: f64, sd: f64 },
49    /// Student t (`qt`) with `df` degrees of freedom.
50    StudentT { df: f64 },
51    /// Exponential (`qexp`) with the given `rate`.
52    Exponential { rate: f64 },
53    /// Half-normal — `|Z|·sd`, the reference for half-normal plots of
54    /// absolute residuals: quantile `sd · qnorm((1 + p) / 2)`.
55    HalfNormal { sd: f64 },
56}
57
58impl Default for QQDistribution {
59    fn default() -> Self {
60        QQDistribution::normal()
61    }
62}
63
64impl QQDistribution {
65    /// Standard normal.
66    pub fn normal() -> Self {
67        QQDistribution::Normal { mean: 0.0, sd: 1.0 }
68    }
69    /// Student t with `df` degrees of freedom.
70    pub fn t(df: f64) -> Self {
71        QQDistribution::StudentT { df }
72    }
73    /// Exponential with rate 1.
74    pub fn exponential() -> Self {
75        QQDistribution::Exponential { rate: 1.0 }
76    }
77    /// Half-normal with `sd = 1`.
78    pub fn half_normal() -> Self {
79        QQDistribution::HalfNormal { sd: 1.0 }
80    }
81
82    /// Quantile function (`q*` in R). NaN for invalid parameters.
83    pub fn quantile(&self, p: f64) -> f64 {
84        match *self {
85            QQDistribution::Normal { mean, sd } if sd > 0.0 => mean + sd * d::qnorm(p),
86            QQDistribution::StudentT { df } if df > 0.0 => d::qt(p, df),
87            QQDistribution::Exponential { rate } if rate > 0.0 => {
88                if !(0.0..=1.0).contains(&p) {
89                    f64::NAN
90                } else {
91                    -(-p).ln_1p() / rate
92                }
93            }
94            QQDistribution::HalfNormal { sd } if sd > 0.0 => {
95                if !(0.0..=1.0).contains(&p) {
96                    f64::NAN
97                } else {
98                    sd * d::qnorm((1.0 + p) / 2.0)
99                }
100            }
101            _ => f64::NAN,
102        }
103    }
104
105    /// Density (`d*` in R) — the pointwise band's standard error uses it.
106    pub fn density(&self, x: f64) -> f64 {
107        match *self {
108            QQDistribution::Normal { mean, sd } if sd > 0.0 => d::dnorm((x - mean) / sd) / sd,
109            QQDistribution::StudentT { df } if df > 0.0 => d::dt(x, df),
110            QQDistribution::Exponential { rate } if rate > 0.0 => {
111                if x < 0.0 {
112                    0.0
113                } else {
114                    rate * (-rate * x).exp()
115                }
116            }
117            QQDistribution::HalfNormal { sd } if sd > 0.0 => {
118                if x < 0.0 {
119                    0.0
120                } else {
121                    2.0 * d::dnorm(x / sd) / sd
122                }
123            }
124            _ => f64::NAN,
125        }
126    }
127
128    fn label(&self) -> &'static str {
129        match self {
130            QQDistribution::Normal { .. } => "norm",
131            QQDistribution::StudentT { .. } => "t",
132            QQDistribution::Exponential { .. } => "exp",
133            QQDistribution::HalfNormal { .. } => "halfnorm",
134        }
135    }
136}
137
138/// The finite sample values of `y`, sorted.
139fn sorted_sample(data: &DataFrame) -> Vec<f64> {
140    let mut v: Vec<f64> = data
141        .column("y")
142        .map(|c| {
143            c.iter()
144                .filter_map(|v| v.as_f64())
145                .filter(|v| v.is_finite())
146                .collect()
147        })
148        .unwrap_or_default();
149    v.sort_by(|a, b| a.total_cmp(b));
150    v
151}
152
153/// Copy the group-identifying columns (first value) onto `n` output rows.
154fn carry_groups(data: &DataFrame, out: &mut DataFrame, n: usize) {
155    for col_name in ["color", "fill", "group", "linetype"] {
156        if let Some(first) = data.column(col_name).and_then(|c| c.first()) {
157            out.add_column(col_name.to_string(), vec![first.clone(); n]);
158        }
159    }
160}
161
162fn floats(v: impl IntoIterator<Item = f64>) -> Vec<Value> {
163    v.into_iter().map(Value::Float).collect()
164}
165
166/// Slope / intercept of the line through the `line_p` quantiles (sample
167/// type-7 vs theoretical), as ggplot2's `stat_qq_line`.
168fn qq_line_coef(sorted: &[f64], dist: &QQDistribution, line_p: (f64, f64)) -> (f64, f64) {
169    let (x1, x2) = (dist.quantile(line_p.0), dist.quantile(line_p.1));
170    let (y1, y2) = (
171        quantile_type7(sorted, line_p.0),
172        quantile_type7(sorted, line_p.1),
173    );
174    let slope = (y2 - y1) / (x2 - x1);
175    (slope, y1 - slope * x1)
176}
177
178/// `stat_qq` against any [`QQDistribution`]: `x` = theoretical quantiles at
179/// `ppoints(n)`, `y` = the sorted sample (also as `theoretical` / `sample`).
180#[derive(Clone, Debug, Default)]
181pub struct StatQQDist {
182    pub distribution: QQDistribution,
183}
184
185impl StatQQDist {
186    pub fn new(distribution: QQDistribution) -> Self {
187        StatQQDist { distribution }
188    }
189}
190
191impl Stat for StatQQDist {
192    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
193        let values = sorted_sample(data);
194        if values.is_empty() {
195            return DataFrame::new();
196        }
197        let n = values.len();
198        let theo: Vec<f64> = ppoints(n)
199            .into_iter()
200            .map(|p| self.distribution.quantile(p))
201            .collect();
202        let mut result = DataFrame::new();
203        result.add_column("x".to_string(), floats(theo.iter().copied()));
204        result.add_column("y".to_string(), floats(values.iter().copied()));
205        result.add_column("theoretical".to_string(), floats(theo));
206        result.add_column("sample".to_string(), floats(values));
207        carry_groups(data, &mut result, n);
208        result
209    }
210
211    fn required_aes(&self) -> Vec<Aesthetic> {
212        vec![Aesthetic::Y]
213    }
214
215    fn name(&self) -> &str {
216        "qq"
217    }
218}
219
220/// `stat_qq_line` against any [`QQDistribution`]: the line through the
221/// `line_p` (default 1st/3rd quartile) points, drawn over the range of the
222/// theoretical quantiles. Output `x`, `y` (2 rows) plus `slope`, `intercept`.
223#[derive(Clone, Debug)]
224pub struct StatQQLineDist {
225    pub distribution: QQDistribution,
226    /// Probabilities of the two quantiles the line passes through
227    /// (ggplot2's `line.p`, default `(0.25, 0.75)`).
228    pub line_p: (f64, f64),
229}
230
231impl Default for StatQQLineDist {
232    fn default() -> Self {
233        StatQQLineDist {
234            distribution: QQDistribution::default(),
235            line_p: (0.25, 0.75),
236        }
237    }
238}
239
240impl StatQQLineDist {
241    pub fn new(distribution: QQDistribution) -> Self {
242        StatQQLineDist {
243            distribution,
244            ..Default::default()
245        }
246    }
247}
248
249impl Stat for StatQQLineDist {
250    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
251        let values = sorted_sample(data);
252        let n = values.len();
253        if n < 2 {
254            return DataFrame::new();
255        }
256        let (slope, intercept) = qq_line_coef(&values, &self.distribution, self.line_p);
257        let pp = ppoints(n);
258        let x_min = self.distribution.quantile(pp[0]);
259        let x_max = self.distribution.quantile(pp[n - 1]);
260        let mut result = DataFrame::new();
261        result.add_column("x".to_string(), floats([x_min, x_max]));
262        result.add_column(
263            "y".to_string(),
264            floats([intercept + slope * x_min, intercept + slope * x_max]),
265        );
266        result.add_column("slope".to_string(), floats([slope, slope]));
267        result.add_column("intercept".to_string(), floats([intercept, intercept]));
268        carry_groups(data, &mut result, 2);
269        result
270    }
271
272    fn required_aes(&self) -> Vec<Aesthetic> {
273        vec![Aesthetic::Y]
274    }
275
276    fn name(&self) -> &str {
277        "qq_line"
278    }
279}
280
281/// Confidence-band construction for [`StatQQBand`] (qqplotr's `bandType`).
282#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
283pub enum QQBandType {
284    /// Pointwise normal-theory band: `fitted ± z · slope/f(q) · √(p(1−p)/n)`.
285    #[default]
286    Pointwise,
287    /// Simultaneous Kolmogorov–Smirnov (DKW) band: the line evaluated at the
288    /// quantiles of `p ± ε`, `ε = √(ln(2/(1−level)) / (2n))`. Probabilities
289    /// clamped to 0 / 1 give `±Inf` bounds, drawn to the panel edge.
290    Ks,
291}
292
293/// `stat_qq_band` (qqplotr): a confidence envelope around the QQ reference
294/// line. Output per sample point: `x` (theoretical quantile), `ymin`, `ymax`
295/// and `y` (the line's fitted value).
296#[derive(Clone, Debug)]
297pub struct StatQQBand {
298    pub distribution: QQDistribution,
299    pub band: QQBandType,
300    /// Confidence level (qqplotr's `conf`, default 0.95).
301    pub level: f64,
302    /// Quantile probabilities of the reference line (default quartiles).
303    pub line_p: (f64, f64),
304}
305
306impl Default for StatQQBand {
307    fn default() -> Self {
308        StatQQBand {
309            distribution: QQDistribution::default(),
310            band: QQBandType::Pointwise,
311            level: 0.95,
312            line_p: (0.25, 0.75),
313        }
314    }
315}
316
317impl StatQQBand {
318    pub fn new(distribution: QQDistribution) -> Self {
319        StatQQBand {
320            distribution,
321            ..Default::default()
322        }
323    }
324    /// Use the given band type.
325    pub fn band(mut self, band: QQBandType) -> Self {
326        self.band = band;
327        self
328    }
329    /// Set the confidence level (e.g. 0.95).
330    pub fn level(mut self, level: f64) -> Self {
331        self.level = level;
332        self
333    }
334}
335
336impl Stat for StatQQBand {
337    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
338        let values = sorted_sample(data);
339        let n = values.len();
340        if n < 2 || !(self.level > 0.0 && self.level < 1.0) {
341            return DataFrame::new();
342        }
343        let dist = &self.distribution;
344        let (slope, intercept) = qq_line_coef(&values, dist, self.line_p);
345        let probs = ppoints(n);
346        let nf = n as f64;
347        let mut xs = Vec::with_capacity(n);
348        let mut fit = Vec::with_capacity(n);
349        let mut lo = Vec::with_capacity(n);
350        let mut hi = Vec::with_capacity(n);
351        let z = d::qnorm(1.0 - (1.0 - self.level) / 2.0);
352        let eps = ((2.0 / (1.0 - self.level)).ln() / (2.0 * nf)).sqrt();
353        for &p in &probs {
354            let q = dist.quantile(p);
355            let fitted = intercept + slope * q;
356            let (l, u) = match self.band {
357                QQBandType::Pointwise => {
358                    let se = slope / dist.density(q) * (p * (1.0 - p) / nf).sqrt();
359                    (fitted - z * se, fitted + z * se)
360                }
361                QQBandType::Ks => {
362                    let lp = (p - eps).max(0.0);
363                    let up = (p + eps).min(1.0);
364                    (
365                        intercept + slope * dist.quantile(lp),
366                        intercept + slope * dist.quantile(up),
367                    )
368                }
369            };
370            // A negative slope (or a degenerate one) swaps the bounds.
371            let (l, u) = if l <= u { (l, u) } else { (u, l) };
372            xs.push(q);
373            fit.push(fitted);
374            lo.push(l);
375            hi.push(u);
376        }
377        let mut result = DataFrame::new();
378        result.add_column("x".to_string(), floats(xs));
379        result.add_column("y".to_string(), floats(fit));
380        result.add_column("ymin".to_string(), floats(lo));
381        result.add_column("ymax".to_string(), floats(hi));
382        carry_groups(data, &mut result, n);
383        result
384    }
385
386    fn required_aes(&self) -> Vec<Aesthetic> {
387        vec![Aesthetic::Y]
388    }
389
390    fn name(&self) -> &str {
391        match self.band {
392            QQBandType::Pointwise => "qq_band",
393            QQBandType::Ks => "qq_band_ks",
394        }
395    }
396}
397
398/// StatQQ: sort sample, compute theoretical *standard-normal* quantiles.
399/// Output: x (theoretical quantiles), y (sample sorted). Equivalent to
400/// `StatQQDist::default()`.
401pub struct StatQQ;
402
403impl Stat for StatQQ {
404    fn compute_group(&self, data: &DataFrame, scales: &ScaleSet) -> DataFrame {
405        StatQQDist::default().compute_group(data, scales)
406    }
407
408    fn required_aes(&self) -> Vec<Aesthetic> {
409        vec![Aesthetic::Y]
410    }
411
412    fn name(&self) -> &str {
413        "qq"
414    }
415}
416
417/// StatQQLine: line through the sample/normal 1st and 3rd quartiles.
418/// Equivalent to `StatQQLineDist::default()`.
419pub struct StatQQLine;
420
421impl Stat for StatQQLine {
422    fn compute_group(&self, data: &DataFrame, scales: &ScaleSet) -> DataFrame {
423        StatQQLineDist::default().compute_group(data, scales)
424    }
425
426    fn required_aes(&self) -> Vec<Aesthetic> {
427        vec![Aesthetic::Y]
428    }
429
430    fn name(&self) -> &str {
431        "qq_line"
432    }
433}
434
435impl std::fmt::Display for QQDistribution {
436    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437        f.write_str(self.label())
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    fn sample(vals: &[f64]) -> DataFrame {
446        let mut data = DataFrame::new();
447        data.add_column("y".to_string(), floats(vals.iter().copied()));
448        data
449    }
450
451    #[test]
452    fn test_stat_qq() {
453        let data = sample(&(0..100).map(|i| i as f64).collect::<Vec<_>>());
454        let result = StatQQ.compute_group(&data, &ScaleSet::new());
455        assert_eq!(result.nrows(), 100);
456        let x = result.column("x").unwrap();
457        let y = result.column("y").unwrap();
458        for i in 1..y.len() {
459            assert!(y[i].as_f64().unwrap() >= y[i - 1].as_f64().unwrap());
460            assert!(x[i].as_f64().unwrap() >= x[i - 1].as_f64().unwrap());
461        }
462    }
463
464    #[test]
465    fn test_stat_qq_line() {
466        let data = sample(&(0..100).map(|i| i as f64).collect::<Vec<_>>());
467        let result = StatQQLine.compute_group(&data, &ScaleSet::new());
468        assert_eq!(result.nrows(), 2);
469    }
470
471    #[test]
472    fn quantiles_of_each_distribution() {
473        assert!((QQDistribution::exponential().quantile(0.5) - 2f64.ln()).abs() < 1e-15);
474        let hn = QQDistribution::half_normal();
475        assert!((hn.quantile(0.5) - d::qnorm(0.75)).abs() < 1e-15);
476        assert_eq!(hn.quantile(0.0), 0.0);
477        assert!(QQDistribution::t(0.0).quantile(0.3).is_nan());
478        let n = QQDistribution::Normal { mean: 2.0, sd: 3.0 };
479        assert!((n.quantile(0.975) - (2.0 + 3.0 * 1.959_963_984_540_054)).abs() < 1e-12);
480    }
481
482    #[test]
483    fn band_contains_line_and_ks_reaches_infinity() {
484        let vals: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.37).sin() * 2.0).collect();
485        let data = sample(&vals);
486        let pw = StatQQBand::default().compute_group(&data, &ScaleSet::new());
487        let (lo, mid, hi) = (
488            pw.column("ymin").unwrap(),
489            pw.column("y").unwrap(),
490            pw.column("ymax").unwrap(),
491        );
492        for i in 0..pw.nrows() {
493            let (l, m, h) = (
494                lo[i].as_f64().unwrap(),
495                mid[i].as_f64().unwrap(),
496                hi[i].as_f64().unwrap(),
497            );
498            assert!(l < m && m < h);
499        }
500        let ks = StatQQBand::default()
501            .band(QQBandType::Ks)
502            .compute_group(&data, &ScaleSet::new());
503        let lo = ks.column("ymin").unwrap();
504        assert_eq!(lo[0].as_f64(), Some(f64::NEG_INFINITY));
505    }
506
507    #[test]
508    fn non_finite_and_tiny_samples() {
509        let data = sample(&[f64::NAN, 1.0, f64::INFINITY]);
510        assert_eq!(
511            StatQQDist::default()
512                .compute_group(&data, &ScaleSet::new())
513                .nrows(),
514            1
515        );
516        assert_eq!(
517            StatQQBand::default()
518                .compute_group(&data, &ScaleSet::new())
519                .nrows(),
520            0
521        );
522    }
523}