Skip to main content

qualia_core_db/solvers/statistics/hypothesis/
t_tests.rs

1//! t-tests — one-sample, paired, and two-sample (pooled + Welch). Real Student-t
2//! p-values and t-based confidence intervals.
3
4use super::super::descriptive::{mean, variance};
5use super::super::distributions::students_t;
6
7/// One-sample / paired t-test result (integer degrees of freedom). `Copy`.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct TTest {
10    pub t_statistic: f64,
11    /// Two-sided p-value from the exact Student-t CDF.
12    pub p_value: f64,
13    pub degrees_of_freedom: u32,
14    /// (lower, upper) of the 95% confidence interval around the sample mean, using
15    /// the t critical value for these degrees of freedom (not a fixed 1.96).
16    pub confidence_interval: (f64, f64),
17}
18
19/// One-sample t-test of the sample mean against `mu`. `None` if `n < 2`.
20pub fn one_sample_t(values: &[f64], mu: f64) -> Option<TTest> {
21    let n = values.len();
22    if n < 2 {
23        return None;
24    }
25    let m = mean(values)?;
26    let var = variance(values, true)?;
27    let df = (n - 1) as f64;
28    let std_error = (var / n as f64).sqrt();
29    if std_error == 0.0 {
30        // Degenerate (zero variance): t is ±∞ unless the mean equals mu exactly.
31        let t = if m == mu {
32            0.0
33        } else {
34            f64::INFINITY.copysign(m - mu)
35        };
36        return Some(TTest {
37            t_statistic: t,
38            p_value: if m == mu { 1.0 } else { 0.0 },
39            degrees_of_freedom: (n - 1) as u32,
40            confidence_interval: (m, m),
41        });
42    }
43    let t = (m - mu) / std_error;
44    let p = students_t::two_sided_p(t, df);
45    let t_crit = students_t::quantile(0.975, df);
46    let margin = t_crit * std_error;
47    Some(TTest {
48        t_statistic: t,
49        p_value: p,
50        degrees_of_freedom: (n - 1) as u32,
51        confidence_interval: (m - margin, m + margin),
52    })
53}
54
55/// Paired t-test: the one-sample t-test of the paired differences against 0.
56/// `None` if the samples differ in length or `n < 2`.
57pub fn paired_t(a: &[f64], b: &[f64]) -> Option<TTest> {
58    if a.len() != b.len() || a.len() < 2 {
59        return None;
60    }
61    let diffs: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| x - y).collect();
62    one_sample_t(&diffs, 0.0)
63}
64
65/// Two-sample t-test result (degrees of freedom may be fractional, for Welch).
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct TwoSampleTTest {
68    pub t_statistic: f64,
69    pub p_value: f64,
70    pub degrees_of_freedom: f64,
71    /// Difference of sample means (`mean(a) − mean(b)`).
72    pub mean_difference: f64,
73    /// 95% CI for the difference of means.
74    pub confidence_interval: (f64, f64),
75}
76
77/// Two-sample t-test of `mean(a) − mean(b) = 0`.
78///
79/// `equal_var = true` → the pooled-variance (Student) test; `false` → the
80/// Welch test (unequal variances, the safer default). `None` if either sample has
81/// `n < 2`.
82pub fn two_sample_t(a: &[f64], b: &[f64], equal_var: bool) -> Option<TwoSampleTTest> {
83    let (na, nb) = (a.len(), b.len());
84    if na < 2 || nb < 2 {
85        return None;
86    }
87    let (ma, mb) = (mean(a)?, mean(b)?);
88    let (va, vb) = (variance(a, true)?, variance(b, true)?);
89    let (na_f, nb_f) = (na as f64, nb as f64);
90    let diff = ma - mb;
91
92    let (se, df) = if equal_var {
93        // Pooled variance.
94        let sp2 = ((na_f - 1.0) * va + (nb_f - 1.0) * vb) / (na_f + nb_f - 2.0);
95        let se = (sp2 * (1.0 / na_f + 1.0 / nb_f)).sqrt();
96        (se, na_f + nb_f - 2.0)
97    } else {
98        // Welch–Satterthwaite.
99        let se2 = va / na_f + vb / nb_f;
100        let se = se2.sqrt();
101        let df =
102            se2 * se2 / ((va / na_f).powi(2) / (na_f - 1.0) + (vb / nb_f).powi(2) / (nb_f - 1.0));
103        (se, df)
104    };
105
106    if se == 0.0 {
107        return Some(TwoSampleTTest {
108            t_statistic: if diff == 0.0 {
109                0.0
110            } else {
111                f64::INFINITY.copysign(diff)
112            },
113            p_value: if diff == 0.0 { 1.0 } else { 0.0 },
114            degrees_of_freedom: df,
115            mean_difference: diff,
116            confidence_interval: (diff, diff),
117        });
118    }
119    let t = diff / se;
120    let p = students_t::two_sided_p(t, df);
121    let t_crit = students_t::quantile(0.975, df);
122    let margin = t_crit * se;
123    Some(TwoSampleTTest {
124        t_statistic: t,
125        p_value: p,
126        degrees_of_freedom: df,
127        mean_difference: diff,
128        confidence_interval: (diff - margin, diff + margin),
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn one_sample_real_p_value_not_a_threshold() {
138        // Classic worked example: data with mean ~5.4, test against 5.0.
139        let v = [5.1, 4.9, 5.6, 5.2, 5.8, 5.3, 4.7, 5.5];
140        let r = one_sample_t(&v, 5.0).unwrap();
141        assert_eq!(r.degrees_of_freedom, 7);
142        // p-value is a real number strictly inside (0,1), not 0.05/0.1.
143        assert!(r.p_value > 0.0 && r.p_value < 1.0);
144        assert_ne!(r.p_value, 0.05);
145        assert_ne!(r.p_value, 0.1);
146        // CI brackets the sample mean.
147        let m = v.iter().sum::<f64>() / v.len() as f64;
148        assert!(r.confidence_interval.0 < m && r.confidence_interval.1 > m);
149    }
150
151    #[test]
152    fn one_sample_matches_known_statistic() {
153        // [1,2,3,4,5] vs mu=3: mean=3 → t=0, p=1.
154        let r = one_sample_t(&[1.0, 2.0, 3.0, 4.0, 5.0], 3.0).unwrap();
155        assert!(r.t_statistic.abs() < 1e-12);
156        assert!((r.p_value - 1.0).abs() < 1e-9);
157        // vs mu=0: t = mean/(s/√n) = 3/(√2.5/√5) = 3/0.7071 = 4.2426; p≈0.0133.
158        let r2 = one_sample_t(&[1.0, 2.0, 3.0, 4.0, 5.0], 0.0).unwrap();
159        assert!((r2.t_statistic - 4.242_640_687).abs() < 1e-6);
160        assert!((r2.p_value - 0.013_31).abs() < 1e-4);
161    }
162
163    #[test]
164    fn paired_is_one_sample_of_differences() {
165        let before = [10.0, 12.0, 9.0, 11.0, 13.0];
166        let after = [11.0, 14.0, 10.0, 12.0, 15.0];
167        let r = paired_t(&before, &after).unwrap();
168        // Differences are all -1 or -2 → mean negative, significant.
169        assert!(r.t_statistic < 0.0);
170        assert!(r.p_value < 0.05);
171        assert_eq!(paired_t(&before, &after[..4]), None); // length mismatch
172    }
173
174    #[test]
175    fn welch_vs_pooled_two_sample() {
176        let a = [20.0, 22.0, 19.0, 24.0, 25.0, 21.0];
177        let b = [28.0, 31.0, 26.0, 30.0, 29.0, 27.0];
178        let welch = two_sample_t(&a, &b, false).unwrap();
179        let pooled = two_sample_t(&a, &b, true).unwrap();
180        // Group b is clearly higher → diff negative, both highly significant.
181        assert!(welch.mean_difference < 0.0);
182        assert!(welch.p_value < 0.01 && pooled.p_value < 0.01);
183        // Welch df ≤ pooled df (= n1+n2-2 = 10).
184        assert!(welch.degrees_of_freedom <= 10.0 + 1e-9);
185        assert!((pooled.degrees_of_freedom - 10.0).abs() < 1e-9);
186        // CI for the difference excludes 0 (significant).
187        assert!(welch.confidence_interval.1 < 0.0);
188    }
189
190    #[test]
191    fn identical_groups_are_not_significant() {
192        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
193        let r = two_sample_t(&a, &a, false).unwrap();
194        assert!(r.t_statistic.abs() < 1e-9);
195        assert!((r.p_value - 1.0).abs() < 1e-9);
196    }
197}