Skip to main content

solow_stats/
weightstats.rs

1//! Weighted descriptive statistics and two-sample location tests.
2
3use ndarray::Array1;
4use solow_distributions::{norm_cdf, norm_sf, t_cdf, t_sf};
5
6/// Alternative hypothesis for a location test.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Alternative {
9    /// `H1: parameter != value` (default).
10    TwoSided,
11    /// `H1: parameter > value`.
12    Larger,
13    /// `H1: parameter < value`.
14    Smaller,
15}
16
17/// Result of a t- or z-test: statistic, p-value, and degrees of freedom.
18#[derive(Debug, Clone, Copy)]
19pub struct TTestResult {
20    /// Test statistic.
21    pub statistic: f64,
22    /// p-value of the test.
23    pub pvalue: f64,
24    /// Degrees of freedom (for a z-test this is `+inf` / unused).
25    pub df: f64,
26}
27
28/// Weighted descriptive statistics for a 1-D sample.
29///
30/// Wraps a data vector and (optional) case weights and exposes the weighted
31/// mean, (co)variance with a configurable degrees-of-freedom correction, and a
32/// one-sample t-test of the mean. Mirrors the reference `DescrStatsW`.
33#[derive(Debug, Clone)]
34pub struct DescrStatsW {
35    data: Array1<f64>,
36    weights: Array1<f64>,
37    ddof: f64,
38}
39
40impl DescrStatsW {
41    /// Construct from `data` and optional `weights` (defaulting to all-ones),
42    /// with degrees-of-freedom correction `ddof` (default convention `0`).
43    pub fn new(data: Array1<f64>, weights: Option<Array1<f64>>, ddof: f64) -> Self {
44        let weights = weights.unwrap_or_else(|| Array1::ones(data.len()));
45        assert_eq!(
46            data.len(),
47            weights.len(),
48            "data and weights length mismatch"
49        );
50        DescrStatsW {
51            data,
52            weights,
53            ddof,
54        }
55    }
56
57    /// Sum of the weights (the effective number of observations).
58    pub fn sum_weights(&self) -> f64 {
59        self.weights.sum()
60    }
61
62    /// Number of observations, equal to the sum of weights.
63    pub fn nobs(&self) -> f64 {
64        self.sum_weights()
65    }
66
67    /// Weighted sum of the data.
68    pub fn sum(&self) -> f64 {
69        self.data.dot(&self.weights)
70    }
71
72    /// Weighted mean of the data.
73    pub fn mean(&self) -> f64 {
74        self.sum() / self.sum_weights()
75    }
76
77    /// Weighted sum of squares of the demeaned data.
78    pub fn sumsquares(&self) -> f64 {
79        let m = self.mean();
80        self.data
81            .iter()
82            .zip(self.weights.iter())
83            .map(|(&x, &w)| w * (x - m) * (x - m))
84            .sum()
85    }
86
87    /// Variance with denominator `sum_weights - ddof_override`.
88    pub fn var_ddof(&self, ddof: f64) -> f64 {
89        self.sumsquares() / (self.sum_weights() - ddof)
90    }
91
92    /// Variance with the instance's default `ddof`.
93    pub fn var(&self) -> f64 {
94        self.var_ddof(self.ddof)
95    }
96
97    /// Standard deviation with denominator `sum_weights - ddof_override`.
98    pub fn std_ddof(&self, ddof: f64) -> f64 {
99        self.var_ddof(ddof).sqrt()
100    }
101
102    /// Standard deviation with the instance's default `ddof`.
103    pub fn std(&self) -> f64 {
104        self.var().sqrt()
105    }
106
107    /// Standard error of the weighted mean.
108    ///
109    /// Uses the `ddof`-adjusted standard deviation rescaled to the population
110    /// form and divided by `sqrt(sum_weights - 1)`, exactly as the reference.
111    pub fn std_mean(&self) -> f64 {
112        let mut std = self.std();
113        if self.ddof != 0.0 {
114            std *= ((self.sum_weights() - self.ddof) / self.sum_weights()).sqrt();
115        }
116        std / (self.sum_weights() - 1.0).sqrt()
117    }
118
119    /// One-sample t-test that the (weighted) mean equals `value`.
120    ///
121    /// Returns the statistic `(mean - value) / std_mean`, its t-distribution
122    /// p-value with `sum_weights - 1` degrees of freedom, and that d.o.f.
123    pub fn ttest_mean(&self, value: f64, alternative: Alternative) -> TTestResult {
124        let tstat = (self.mean() - value) / self.std_mean();
125        let dof = self.sum_weights() - 1.0;
126        let pvalue = match alternative {
127            Alternative::TwoSided => t_sf(tstat.abs(), dof) * 2.0,
128            Alternative::Larger => t_sf(tstat, dof),
129            Alternative::Smaller => t_cdf(tstat, dof),
130        };
131        TTestResult {
132            statistic: tstat,
133            pvalue,
134            df: dof,
135        }
136    }
137}
138
139/// Whether a two-sample test assumes pooled (equal) or unequal variances.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum UseVar {
142    /// Equal-variance (pooled) assumption.
143    Pooled,
144    /// Welch / Satterthwaite unequal-variance assumption.
145    Unequal,
146}
147
148/// Two-sample independent t-test of `mean(x1) - mean(x2) == value`.
149///
150/// With `UseVar::Pooled` the pooled-variance Student t-test is used (d.o.f.
151/// `n1 + n2 - 2`); with `UseVar::Unequal` the Welch test with Satterthwaite
152/// d.o.f. is used. Mirrors the reference `ttest_ind` (unweighted samples).
153pub fn ttest_ind(
154    x1: &Array1<f64>,
155    x2: &Array1<f64>,
156    alternative: Alternative,
157    usevar: UseVar,
158    value: f64,
159) -> TTestResult {
160    let d1 = DescrStatsW::new(x1.clone(), None, 0.0);
161    let d2 = DescrStatsW::new(x2.clone(), None, 0.0);
162    let n1 = d1.nobs();
163    let n2 = d2.nobs();
164    let ss1 = d1.sumsquares();
165    let ss2 = d2.sumsquares();
166    // `_var` in the reference is the population variance (ddof = 0).
167    let var1 = ss1 / n1;
168    let var2 = ss2 / n2;
169
170    let (stdm, dof) = match usevar {
171        UseVar::Pooled => {
172            let var_pooled = (ss1 + ss2) / (n1 - 1.0 + n2 - 1.0);
173            let stdm = (var_pooled * (1.0 / n1 + 1.0 / n2)).sqrt();
174            (stdm, n1 - 1.0 + n2 - 1.0)
175        }
176        UseVar::Unequal => {
177            let sem1 = var1 / (n1 - 1.0);
178            let sem2 = var2 / (n2 - 1.0);
179            let semsum = sem1 + sem2;
180            let stdm = semsum.sqrt();
181            let z1 = (sem1 / semsum).powi(2) / (n1 - 1.0);
182            let z2 = (sem2 / semsum).powi(2) / (n2 - 1.0);
183            let dof = 1.0 / (z1 + z2);
184            (stdm, dof)
185        }
186    };
187
188    let tstat = (d1.mean() - d2.mean() - value) / stdm;
189    let pvalue = match alternative {
190        Alternative::TwoSided => t_sf(tstat.abs(), dof) * 2.0,
191        Alternative::Larger => t_sf(tstat, dof),
192        Alternative::Smaller => t_cdf(tstat, dof),
193    };
194    TTestResult {
195        statistic: tstat,
196        pvalue,
197        df: dof,
198    }
199}
200
201/// One-sample z-test that `mean(x1) == value`.
202///
203/// Uses the population variance of `x1` with a `ddof` correction (default
204/// convention `ddof = 1`) for the standard error, referenced to the standard
205/// normal distribution. Returns the z-statistic and p-value (`df` is `+inf`).
206/// Mirrors the one-sample branch of the reference `ztest`.
207pub fn ztest(x1: &Array1<f64>, value: f64, alternative: Alternative, ddof: f64) -> TTestResult {
208    let n1 = x1.len() as f64;
209    let mean = x1.sum() / n1;
210    // numpy var(0): population variance, divided by n.
211    let var0 = x1.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n1;
212    let var = var0 / (n1 - ddof);
213    let std_diff = var.sqrt();
214    let zstat = (mean - value) / std_diff;
215    let pvalue = match alternative {
216        Alternative::TwoSided => norm_sf(zstat.abs()) * 2.0,
217        Alternative::Larger => norm_sf(zstat),
218        Alternative::Smaller => norm_cdf(zstat),
219    };
220    TTestResult {
221        statistic: zstat,
222        pvalue,
223        df: f64::INFINITY,
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use ndarray::array;
231
232    #[test]
233    fn unweighted_mean_matches_plain_mean() {
234        let d = DescrStatsW::new(array![1.0, 2.0, 3.0, 4.0], None, 0.0);
235        assert!((d.mean() - 2.5).abs() < 1e-12);
236        assert!((d.sum_weights() - 4.0).abs() < 1e-12);
237    }
238
239    #[test]
240    fn weighted_mean_basic() {
241        let d = DescrStatsW::new(array![1.0, 3.0], Some(array![1.0, 3.0]), 0.0);
242        // (1*1 + 3*3) / 4 = 2.5
243        assert!((d.mean() - 2.5).abs() < 1e-12);
244    }
245}