Skip to main content

solow_stats/
anova.rs

1//! Analysis-of-variance tables for a fitted linear model (types I, II, III).
2//!
3//! Mirrors the reference `anova_lm` for a single model. Because this crate has
4//! no formula parser, the caller supplies the model in *term* form: the design
5//! matrix `exog`, the response `endog`, and a list of named terms each mapping
6//! to a contiguous column range of `exog` (a categorical factor with `m` levels
7//! occupies `m − 1` columns, exactly as the reference's `design_info`). The
8//! intercept term, if present, must be the first term and span column 0.
9//!
10//! - **Type I** (sequential) uses the QR "effects" of the design: each term's
11//!   sum of squares is the squared length of the effects in its columns.
12//! - **Type II** (marginal, hierarchy-respecting) and **Type III** (marginal,
13//!   each term adjusted for all others) form a linear restriction `L` for each
14//!   term and back the sum of squares out of the general linear F-test.
15
16use ndarray::{Array1, Array2};
17use solow_core::error::{Error, Result};
18use solow_distributions::f_sf;
19use solow_linalg::{pinv, qr};
20
21/// Type of sum of squares for [`anova_lm`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum AnovaType {
24    /// Sequential (Type I).
25    I,
26    /// Marginal, hierarchy-respecting (Type II).
27    II,
28    /// Marginal, fully adjusted (Type III).
29    III,
30}
31
32/// A named model term and the half-open column range `[start, stop)` it spans
33/// in the design matrix.
34#[derive(Debug, Clone)]
35pub struct Term {
36    /// Term label, e.g. `"C(a)"` or `"C(a):C(b)"`. The interaction separator is
37    /// `:`; factor names are the colon-separated pieces.
38    pub name: String,
39    /// First column (inclusive) of this term in `exog`.
40    pub start: usize,
41    /// Last column (exclusive) of this term in `exog`.
42    pub stop: usize,
43}
44
45impl Term {
46    /// Construct a term spanning columns `[start, stop)`.
47    pub fn new(name: impl Into<String>, start: usize, stop: usize) -> Self {
48        Term {
49            name: name.into(),
50            start,
51            stop,
52        }
53    }
54
55    /// Set of constituent factor names (split on the interaction separator).
56    fn factors(&self) -> Vec<&str> {
57        self.name.split(':').collect()
58    }
59}
60
61/// One row of an ANOVA table.
62#[derive(Debug, Clone)]
63pub struct AnovaRow {
64    /// Row label (a term name, or `"Residual"`).
65    pub name: String,
66    /// Degrees of freedom.
67    pub df: f64,
68    /// Sum of squares.
69    pub sum_sq: f64,
70    /// Mean square `sum_sq / df`.
71    pub mean_sq: f64,
72    /// F statistic (`None` for the residual row).
73    pub f: Option<f64>,
74    /// p-value `PR(>F)` (`None` for the residual row).
75    pub pr: Option<f64>,
76}
77
78/// A full ANOVA table.
79#[derive(Debug, Clone)]
80pub struct AnovaTable {
81    /// Table rows, in display order (terms first, then `Residual`).
82    pub rows: Vec<AnovaRow>,
83}
84
85impl AnovaTable {
86    /// Look up a row by its label.
87    pub fn row(&self, name: &str) -> Option<&AnovaRow> {
88        self.rows.iter().find(|r| r.name == name)
89    }
90}
91
92/// Whether `name` denotes the intercept term.
93fn is_intercept(name: &str) -> bool {
94    name == "Intercept"
95}
96
97/// ANOVA table for one fitted linear model. Mirrors the single-model
98/// `anova_lm(model, typ=...)`.
99///
100/// `exog`/`endog` are the design and response, `terms` the named column groups,
101/// and `typ` the sum-of-squares type. The model is refit internally by ordinary
102/// least squares (pseudo-inverse), matching the reference's OLS fit.
103pub fn anova_lm(
104    endog: &Array1<f64>,
105    exog: &Array2<f64>,
106    terms: &[Term],
107    typ: AnovaType,
108) -> Result<AnovaTable> {
109    let (nobs, kcols) = exog.dim();
110
111    // OLS fit via the pseudo-inverse (pinv returns (A^+, singular values)).
112    let (pinv_x, sv) = pinv(exog)?;
113    let beta = pinv_x.dot(endog);
114    let fitted = exog.dot(&beta);
115    let resid = endog - &fitted;
116    let ssr = resid.dot(&resid);
117
118    // Rank and residual degrees of freedom (reference matrix_rank convention).
119    let smax = sv.iter().cloned().fold(0.0_f64, f64::max);
120    let tol = smax * (sv.len() as f64) * f64::EPSILON;
121    let rank = sv.iter().filter(|&&s| s > tol).count();
122    let df_resid = nobs as f64 - rank as f64;
123    let scale = ssr / df_resid;
124
125    let rows = match typ {
126        AnovaType::I => anova_type1(endog, exog, terms, ssr, df_resid),
127        AnovaType::II | AnovaType::III => {
128            // Parameter covariance: scale · (XᵀX)^+.
129            let xtx = exog.t().dot(exog);
130            let (xtx_pinv, _) = pinv(&xtx)?;
131            let cov = &xtx_pinv * scale;
132            if typ == AnovaType::III {
133                anova_type3(&beta, &cov, terms, kcols, ssr, df_resid)?
134            } else {
135                anova_type2(&beta, &cov, terms, kcols, ssr, df_resid)?
136            }
137        }
138    };
139
140    Ok(AnovaTable { rows })
141}
142
143/// Assemble a finished row given its sum of squares and degrees of freedom.
144fn make_row(name: &str, sum_sq: f64, df: f64, scale: f64, df_resid: f64) -> AnovaRow {
145    let mean_sq = sum_sq / df;
146    let f = mean_sq / scale;
147    let pr = f_sf(f, df, df_resid);
148    AnovaRow {
149        name: name.to_string(),
150        df,
151        sum_sq,
152        mean_sq,
153        f: Some(f),
154        pr: Some(pr),
155    }
156}
157
158/// Residual row (no F / p-value).
159fn residual_row(ssr: f64, df_resid: f64) -> AnovaRow {
160    AnovaRow {
161        name: "Residual".to_string(),
162        df: df_resid,
163        sum_sq: ssr,
164        mean_sq: ssr / df_resid,
165        f: None,
166        pr: None,
167    }
168}
169
170/// Type I (sequential) sums of squares via the QR effects.
171fn anova_type1(
172    endog: &Array1<f64>,
173    exog: &Array2<f64>,
174    terms: &[Term],
175    ssr: f64,
176    df_resid: f64,
177) -> Vec<AnovaRow> {
178    let scale = ssr / df_resid;
179    // effects = Qᵀ y from the reduced QR of the design.
180    let (q, _r) = qr(exog).expect("qr of design");
181    let effects = q.t().dot(endog);
182
183    let mut rows = Vec::new();
184    for t in terms {
185        if is_intercept(&t.name) {
186            continue;
187        }
188        let mut ss = 0.0;
189        for c in t.start..t.stop {
190            ss += effects[c] * effects[c];
191        }
192        let df = (t.stop - t.start) as f64;
193        rows.push(make_row(&t.name, ss, df, scale, df_resid));
194    }
195    rows.push(residual_row(ssr, df_resid));
196    rows
197}
198
199/// Type III sums of squares: each term tested as `term == 0` adjusting for all
200/// others. The restriction `L` is the identity rows of the term's columns.
201fn anova_type3(
202    beta: &Array1<f64>,
203    cov: &Array2<f64>,
204    terms: &[Term],
205    kcols: usize,
206    ssr: f64,
207    df_resid: f64,
208) -> Result<Vec<AnovaRow>> {
209    let scale = ssr / df_resid;
210    let mut rows = Vec::new();
211    for t in terms {
212        let l = identity_rows(kcols, &(t.start..t.stop).collect::<Vec<_>>());
213        let (fstat, r) = f_test(&l, beta, cov)?;
214        let df = r as f64;
215        let ss = fstat * df * scale;
216        rows.push(make_row(&t.name, ss, df, scale, df_resid));
217    }
218    rows.push(residual_row(ssr, df_resid));
219    Ok(rows)
220}
221
222/// Type II sums of squares: each term tested against the model that contains
223/// all terms not marginal-to it, using the orthogonal-complement restriction.
224fn anova_type2(
225    beta: &Array1<f64>,
226    cov: &Array2<f64>,
227    terms: &[Term],
228    kcols: usize,
229    ssr: f64,
230    df_resid: f64,
231) -> Result<Vec<AnovaRow>> {
232    let scale = ssr / df_resid;
233    // Terms excluding the intercept.
234    let model_terms: Vec<&Term> = terms.iter().filter(|t| !is_intercept(&t.name)).collect();
235
236    let mut rows = Vec::new();
237    for term in &model_terms {
238        let mut l1_cols: Vec<usize> = (term.start..term.stop).collect();
239        let mut l2_cols: Vec<usize> = Vec::new();
240        let term_set: Vec<&str> = term.factors();
241        for t in &model_terms {
242            let other: Vec<&str> = t.factors();
243            // term is a strict subset of t (higher-order term containing it).
244            if is_strict_subset(&term_set, &other) {
245                l1_cols.extend(t.start..t.stop);
246                l2_cols.extend(t.start..t.stop);
247            }
248        }
249        let l1 = identity_rows(kcols, &l1_cols);
250        let (l12, r) = if !l2_cols.is_empty() {
251            let l2 = identity_rows(kcols, &l2_cols);
252            // LVL = L1 cov L2ᵀ ; take the last r columns of the full QR of LVL.
253            let lvl = l1.dot(cov).dot(&l2.t());
254            let rr = l1.nrows() - l2.nrows();
255            let orth_compl = full_q(&lvl)?;
256            let ncolq = orth_compl.ncols();
257            let comp = orth_compl.slice(ndarray::s![.., (ncolq - rr)..]).to_owned();
258            (comp.t().dot(&l1), rr)
259        } else {
260            (l1.clone(), l1.nrows())
261        };
262
263        let (fstat, _jr) = f_test(&l12, beta, cov)?;
264        let df = r as f64;
265        let ss = fstat * df * scale;
266        rows.push(make_row(&term.name, ss, df, scale, df_resid));
267    }
268    rows.push(residual_row(ssr, df_resid));
269    Ok(rows)
270}
271
272/// `a` is a strict subset of `b` (set semantics over factor names).
273fn is_strict_subset(a: &[&str], b: &[&str]) -> bool {
274    if a.len() >= b.len() {
275        return false;
276    }
277    a.iter().all(|x| b.contains(x))
278}
279
280/// Build the restriction matrix `L` of identity rows for `cols`, shape
281/// `cols.len() × kcols`.
282fn identity_rows(kcols: usize, cols: &[usize]) -> Array2<f64> {
283    let mut l = Array2::<f64>::zeros((cols.len(), kcols));
284    for (i, &c) in cols.iter().enumerate() {
285        l[[i, c]] = 1.0;
286    }
287    l
288}
289
290/// General linear F-test for the restriction `L b = 0`.
291///
292/// Returns `(F, J)` where `J = rank(L)` rows and
293/// `F = (Lb)ᵀ (L cov Lᵀ)^+ (Lb) / J`. Mirrors the reference `f_test`.
294fn f_test(l: &Array2<f64>, beta: &Array1<f64>, cov: &Array2<f64>) -> Result<(f64, usize)> {
295    let rb = l.dot(beta);
296    let cov_l = l.dot(cov).dot(&l.t());
297    let (cov_l_pinv, _) = pinv(&cov_l)?;
298    let quad = rb.dot(&cov_l_pinv.dot(&rb));
299    let j = l.nrows();
300    Ok((quad / j as f64, j))
301}
302
303/// Full (square) `Q` of the QR decomposition of `a` (shape `m × n`, `m ≥ n`),
304/// returned as an `m × m` orthogonal matrix. Implemented with local Householder
305/// reflectors so the orthogonal complement (last `m − rank` columns) is
306/// available, which the economy QR does not expose.
307fn full_q(a: &Array2<f64>) -> Result<Array2<f64>> {
308    let (m, n) = a.dim();
309    if m < n {
310        return Err(Error::Shape("full_q requires rows >= cols".into()));
311    }
312    let mut r = a.clone();
313    let mut q = Array2::<f64>::eye(m);
314    for k in 0..n {
315        let mut norm = 0.0;
316        for i in k..m {
317            norm += r[[i, k]] * r[[i, k]];
318        }
319        let norm = norm.sqrt();
320        if norm == 0.0 {
321            continue;
322        }
323        let alpha = if r[[k, k]] >= 0.0 { -norm } else { norm };
324        let mut v = vec![0.0; m];
325        v[k] = r[[k, k]] - alpha;
326        for (i, vi) in v.iter_mut().enumerate().skip(k + 1) {
327            *vi = r[[i, k]];
328        }
329        let mut vnorm2 = 0.0;
330        for vi in v.iter().skip(k) {
331            vnorm2 += vi * vi;
332        }
333        if vnorm2 == 0.0 {
334            continue;
335        }
336        // Apply H to R.
337        for j in k..n {
338            let mut dot = 0.0;
339            for i in k..m {
340                dot += v[i] * r[[i, j]];
341            }
342            let b = 2.0 * dot / vnorm2;
343            for i in k..m {
344                r[[i, j]] -= b * v[i];
345            }
346        }
347        // Apply H to all columns of Q.
348        for j in 0..m {
349            let mut dot = 0.0;
350            for i in k..m {
351                dot += v[i] * q[[j, i]];
352            }
353            let b = 2.0 * dot / vnorm2;
354            for i in k..m {
355                q[[j, i]] -= b * v[i];
356            }
357        }
358    }
359    Ok(q)
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use ndarray::array;
366
367    #[test]
368    fn simple_regression_type1() {
369        // y = 1 + 2 x exactly: one term, residual SS = 0.
370        let exog = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
371        let endog = array![1.0, 3.0, 5.0, 7.0];
372        let terms = vec![Term::new("Intercept", 0, 1), Term::new("x", 1, 2)];
373        let tab = anova_lm(&endog, &exog, &terms, AnovaType::I).unwrap();
374        let x = tab.row("x").unwrap();
375        assert!(x.sum_sq > 0.0);
376        let res = tab.row("Residual").unwrap();
377        assert!(res.sum_sq.abs() < 1e-18);
378    }
379
380    #[test]
381    fn strict_subset_logic() {
382        assert!(is_strict_subset(&["a"], &["a", "b"]));
383        assert!(!is_strict_subset(&["a", "b"], &["a", "b"]));
384        assert!(!is_strict_subset(&["c"], &["a", "b"]));
385    }
386}