Skip to main content

solow_stats/
descriptivestats.rs

1//! Extended descriptive statistics for a numeric sample.
2//!
3//! [`describe`] computes the standard battery of summary statistics for a 1-D
4//! numeric sample (location, dispersion, shape, mode, percentiles and the
5//! Jarque–Bera normality test), mirroring the numeric columns of the reference
6//! `descriptivestats.describe` / `Description`.
7
8use crate::normality::jarque_bera;
9use ndarray::Array1;
10use solow_distributions::{norm_ppf, t_ppf};
11
12/// Percentile levels reported by [`describe`] (the reference default).
13pub const PERCENTILES: [f64; 9] = [1.0, 5.0, 10.0, 25.0, 50.0, 75.0, 90.0, 95.0, 99.0];
14
15/// Summary statistics for a numeric sample (the numeric block of the reference
16/// `Description`). All values are computed with the same conventions as the
17/// reference: `std` uses `ddof = 1`, percentiles use linear interpolation.
18#[derive(Debug, Clone)]
19pub struct Description {
20    /// Number of observations.
21    pub nobs: f64,
22    /// Number of missing (NaN) observations dropped before computation.
23    pub missing: f64,
24    /// Sample mean.
25    pub mean: f64,
26    /// Standard error of the mean, `std / sqrt(nobs)`.
27    pub std_err: f64,
28    /// Upper confidence limit `mean + q * std_err`.
29    pub upper_ci: f64,
30    /// Lower confidence limit `mean - q * std_err`.
31    pub lower_ci: f64,
32    /// Sample standard deviation (`ddof = 1`).
33    pub std: f64,
34    /// Interquartile range, `q75 - q25`.
35    pub iqr: f64,
36    /// IQR rescaled to a normal-distribution standard deviation.
37    pub iqr_normal: f64,
38    /// Mean absolute deviation about the mean.
39    pub mad: f64,
40    /// MAD rescaled to a normal-distribution standard deviation.
41    pub mad_normal: f64,
42    /// Coefficient of variation, `std / mean`.
43    pub coef_var: f64,
44    /// Range, `max - min`.
45    pub range: f64,
46    /// Maximum value.
47    pub max: f64,
48    /// Minimum value.
49    pub min: f64,
50    /// Sample skewness (biased estimator).
51    pub skew: f64,
52    /// Sample kurtosis (biased, non-excess: a normal gives `3`).
53    pub kurtosis: f64,
54    /// Jarque–Bera statistic.
55    pub jarque_bera: f64,
56    /// Jarque–Bera chi-squared(2) p-value.
57    pub jarque_bera_pval: f64,
58    /// Mode (smallest most-frequent value).
59    pub mode: f64,
60    /// Relative frequency of the mode, `count / nobs`.
61    pub mode_freq: f64,
62    /// Median (the 50th percentile).
63    pub median: f64,
64    /// Percentile values at [`PERCENTILES`].
65    pub percentiles: Vec<f64>,
66}
67
68/// Linear-interpolation percentile, matching `numpy.percentile` / pandas
69/// `quantile` (method "linear"). `sorted` must be in ascending order and `q` is
70/// a probability in `[0, 1]`.
71fn percentile_sorted(sorted: &[f64], q: f64) -> f64 {
72    let n = sorted.len();
73    if n == 1 {
74        return sorted[0];
75    }
76    let pos = q * (n as f64 - 1.0);
77    let lo = pos.floor() as usize;
78    let hi = pos.ceil() as usize;
79    if lo == hi {
80        return sorted[lo];
81    }
82    let frac = pos - lo as f64;
83    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
84}
85
86/// Compute the descriptive statistics of a numeric sample.
87///
88/// NaN entries are dropped (and counted in `missing`). `alpha` sets the
89/// confidence-interval coverage to `1 - alpha`; with `use_t = true` the
90/// critical value is from the Student-t distribution with `nobs - 1` degrees of
91/// freedom, otherwise from the standard normal. Mirrors the numeric block of
92/// the reference `describe`.
93pub fn describe(data: &Array1<f64>, alpha: f64, use_t: bool) -> Description {
94    let total = data.len() as f64;
95    let mut clean: Vec<f64> = data.iter().copied().filter(|v| !v.is_nan()).collect();
96    let n = clean.len() as f64;
97    let missing = total - n;
98
99    let mean = clean.iter().sum::<f64>() / n;
100    // ddof = 1 sample variance / std.
101    let var = clean.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (n - 1.0);
102    let std = var.sqrt();
103    let std_err = std / n.sqrt();
104
105    let q = if use_t {
106        t_ppf(1.0 - alpha / 2.0, n - 1.0)
107    } else {
108        norm_ppf(1.0 - alpha / 2.0)
109    };
110
111    clean.sort_by(|a, b| a.total_cmp(b));
112    let q25 = percentile_sorted(&clean, 0.25);
113    let q50 = percentile_sorted(&clean, 0.5);
114    let q75 = percentile_sorted(&clean, 0.75);
115    let iqr = q75 - q25;
116    // iqr_normal divisor: norm.ppf(0.75) - norm.ppf(0.25).
117    let iqr_normal = iqr / (norm_ppf(0.75) - norm_ppf(0.25));
118
119    let mad = clean.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
120    let mad_normal = mad / (2.0 / std::f64::consts::PI).sqrt();
121
122    let coef_var = std / mean;
123    let max = *clean.last().unwrap();
124    let min = clean[0];
125    let range = max - min;
126
127    let jb = jarque_bera(&Array1::from(clean.clone()));
128
129    // Mode: smallest value with the largest count (scipy.stats.mode convention).
130    let (mode, mode_count) = compute_mode(&clean);
131    let mode_freq = mode_count / n;
132
133    let percentiles: Vec<f64> = PERCENTILES
134        .iter()
135        .map(|&p| percentile_sorted(&clean, p / 100.0))
136        .collect();
137
138    Description {
139        nobs: n,
140        missing,
141        mean,
142        std_err,
143        upper_ci: mean + q * std_err,
144        lower_ci: mean - q * std_err,
145        std,
146        iqr,
147        iqr_normal,
148        mad,
149        mad_normal,
150        coef_var,
151        range,
152        max,
153        min,
154        skew: jb.skew,
155        kurtosis: jb.kurtosis,
156        jarque_bera: jb.statistic,
157        jarque_bera_pval: jb.pvalue,
158        mode,
159        mode_freq,
160        median: q50,
161        percentiles,
162    }
163}
164
165/// Smallest most-frequent value and its count, on a pre-sorted slice.
166fn compute_mode(sorted: &[f64]) -> (f64, f64) {
167    let mut best_val = sorted[0];
168    let mut best_count = 0usize;
169    let mut i = 0;
170    while i < sorted.len() {
171        let v = sorted[i];
172        let mut j = i;
173        while j < sorted.len() && sorted[j] == v {
174            j += 1;
175        }
176        let count = j - i;
177        if count > best_count {
178            best_count = count;
179            best_val = v;
180        }
181        i = j;
182    }
183    (best_val, best_count as f64)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use ndarray::array;
190
191    #[test]
192    fn basic_mean_and_median() {
193        let x = array![1.0, 2.0, 3.0, 4.0, 5.0];
194        let d = describe(&x, 0.05, false);
195        assert!((d.mean - 3.0).abs() < 1e-12);
196        assert!((d.median - 3.0).abs() < 1e-12);
197        assert!((d.nobs - 5.0).abs() < 1e-12);
198    }
199
200    #[test]
201    fn nan_counts_as_missing() {
202        let x = array![1.0, f64::NAN, 3.0];
203        let d = describe(&x, 0.05, false);
204        assert!((d.missing - 1.0).abs() < 1e-12);
205        assert!((d.nobs - 2.0).abs() < 1e-12);
206    }
207}