Skip to main content

solow_stats/
regdiag.rs

1//! Ordinary-least-squares residual diagnostics: serial-correlation,
2//! functional-form, and conditional-heteroscedasticity tests, plus nested-model
3//! comparison statistics.
4//!
5//! Each test builds a small auxiliary OLS fit (via [`solow_regression`]) on the
6//! supplied residuals or design and reports a Lagrange-multiplier (`nobs · R²`,
7//! chi-squared) statistic alongside the matching F statistic from the parameter
8//! restriction. The implementations mirror the reference
9//! `…stats.diagnostic` (`acorr_breusch_godfrey`, `linear_reset`, `het_arch`,
10//! `acorr_lm`) and the `compare_lr_test` / `compare_f_test` methods of the
11//! linear-regression results object.
12
13use ndarray::{Array1, Array2};
14use solow_core::error::{Error, Result};
15use solow_distributions::{chi2_sf, f_sf};
16use solow_linalg::inv;
17use solow_regression::{LinearModel, LinearResults};
18
19/// Build the matrix of lagged columns of `x` with `trim="both"` semantics.
20///
21/// For a series of length `n` and `k` lags this returns an `(n − k) × k` matrix
22/// whose row `t` (for `t = 0 .. n−k`) holds `[x[k+t−1], x[k+t−2], …, x[k+t−k]]`,
23/// i.e. column `j` is lag `j + 1`. Mirrors `lagmat(x, k, trim="both")`.
24fn lagmat_both(x: &[f64], k: usize) -> Array2<f64> {
25    let n = x.len();
26    let rows = n - k;
27    let mut out = Array2::<f64>::zeros((rows, k));
28    for t in 0..rows {
29        // The "current" index in the original series is `k + t`.
30        let base = k + t;
31        for j in 0..k {
32            out[[t, j]] = x[base - 1 - j];
33        }
34    }
35    out
36}
37
38/// Horizontally stack a leading column of ones in front of `m`.
39fn with_const(m: &Array2<f64>) -> Array2<f64> {
40    let (r, c) = m.dim();
41    let mut out = Array2::<f64>::zeros((r, c + 1));
42    for i in 0..r {
43        out[[i, 0]] = 1.0;
44        for j in 0..c {
45            out[[i, j + 1]] = m[[i, j]];
46        }
47    }
48    out
49}
50
51/// Default highest lag: `min(10, nobs / 5)`, matching the reference rule.
52fn default_nlags(nobs: usize) -> usize {
53    (nobs / 5).min(10)
54}
55
56/// Wald test of the linear restriction `R · params = 0` for a fitted OLS model.
57///
58/// With `use_f = false` returns the chi-squared form
59/// `(Rβ)' [R · cov_params · R']⁻¹ (Rβ)` and its `chi²(J)` survival p-value;
60/// with `use_f = true` returns that quadratic form divided by `J` together with
61/// its `F(J, df_resid)` survival p-value. `r_mat` is `J × k`. Mirrors the
62/// reference `wald_test(r_mat, use_f=…, scalar=True)`.
63fn wald_test(res: &LinearResults, r_mat: &Array2<f64>, use_f: bool) -> Result<(f64, f64)> {
64    let j = r_mat.nrows();
65    let rb = r_mat.dot(&res.params); // J
66    let rv = r_mat.dot(&res.cov_params); // J × k
67    let rvr = rv.dot(&r_mat.t()); // J × J
68    let rvr_inv = inv(&rvr)?;
69    let mid = rvr_inv.dot(&rb); // J
70    let quad = rb.dot(&mid);
71    if use_f {
72        let stat = quad / j as f64;
73        Ok((stat, f_sf(stat, j as f64, res.df_resid)))
74    } else {
75        Ok((quad, chi2_sf(quad, j as f64)))
76    }
77}
78
79/// Generic Lagrange-multiplier test for autocorrelation (Engle's `acorr_lm`).
80///
81/// Regresses `resid[k..]` on a constant and `k = nlags` of its own lags and
82/// reports `lm = (nobs − ddof) · R²` with a `chi²(k)` p-value, plus the
83/// auxiliary regression's overall F statistic and p-value. When `nlags` is
84/// `None` the reference default `min(10, nobs / 5)` is used. Returns
85/// `(lm, lm_pvalue, fvalue, f_pvalue)`. Mirrors the reference `acorr_lm` with
86/// `cov_type="nonrobust"`.
87pub fn acorr_lm(
88    resid: &Array1<f64>,
89    nlags: Option<usize>,
90    ddof: usize,
91) -> Result<(f64, f64, f64, f64)> {
92    let n = resid.len();
93    let k = nlags.unwrap_or_else(|| default_nlags(n));
94    if k == 0 {
95        return Err(Error::Value("nlags must be >= 1".into()));
96    }
97    if k >= n {
98        return Err(Error::Value("nlags too large for series length".into()));
99    }
100    let r: Vec<f64> = resid.to_vec();
101    let lags = lagmat_both(&r, k);
102    let nobs = lags.nrows();
103    let design = with_const(&lags);
104    let yshort = Array1::from_iter(r[n - nobs..].iter().copied());
105    let res = LinearModel::ols(yshort, design)?.fit()?;
106    let lm = (nobs as f64 - ddof as f64) * res.rsquared;
107    let lm_pvalue = chi2_sf(lm, k as f64);
108    Ok((lm, lm_pvalue, res.fvalue, res.f_pvalue))
109}
110
111/// Engle's ARCH Lagrange-multiplier test (`het_arch`).
112///
113/// Equivalent to [`acorr_lm`] applied to the *squared* residuals: it tests for
114/// autoregressive conditional heteroscedasticity. Returns
115/// `(lm, lm_pvalue, fvalue, f_pvalue)`. Mirrors the reference `het_arch`.
116pub fn het_arch(
117    resid: &Array1<f64>,
118    nlags: Option<usize>,
119    ddof: usize,
120) -> Result<(f64, f64, f64, f64)> {
121    let sq = resid.mapv(|v| v * v);
122    acorr_lm(&sq, nlags, ddof)
123}
124
125/// Breusch–Godfrey Lagrange-multiplier test for residual autocorrelation.
126///
127/// Takes the OLS residuals together with the *original* model design `exog` and
128/// runs the auxiliary regression of the residuals on `exog` augmented with a
129/// constant and `nlags` lags of the residuals (lags before the sample start are
130/// zero-padded, matching the reference). Reports `lm = nobs · R²` with a
131/// `chi²(nlags)` p-value and the F statistic of the joint restriction that all
132/// `nlags` lag-coefficients are zero, with its `F(nlags, df_resid)` p-value.
133/// When `nlags` is `None` the reference default `min(10, nobs / 5)` is used.
134/// Returns `(lm, lm_pvalue, fvalue, f_pvalue)`. Mirrors the reference
135/// `acorr_breusch_godfrey`.
136pub fn acorr_breusch_godfrey(
137    resid: &Array1<f64>,
138    exog: &Array2<f64>,
139    nlags: Option<usize>,
140) -> Result<(f64, f64, f64, f64)> {
141    let n = resid.len();
142    if exog.nrows() != n {
143        return Err(Error::Shape("exog rows must equal residual length".into()));
144    }
145    let k = nlags.unwrap_or_else(|| default_nlags(n));
146    if k == 0 {
147        return Err(Error::Value("nlags must be >= 1".into()));
148    }
149    // Prepend `k` zeros to the residual series, then take both-trimmed lags so
150    // the auxiliary sample has exactly `n` rows.
151    let mut padded = vec![0.0; k];
152    padded.extend(resid.iter().copied());
153    let lags = lagmat_both(&padded, k); // n × k
154    let nobs = lags.nrows();
155    debug_assert_eq!(nobs, n);
156    let lag_const = with_const(&lags); // n × (k+1)
157
158    // exog | 1 | lag1 .. lagk
159    let k_old = exog.ncols();
160    let k_vars = k_old + lag_const.ncols();
161    let mut design = Array2::<f64>::zeros((nobs, k_vars));
162    for i in 0..nobs {
163        for j in 0..k_old {
164            design[[i, j]] = exog[[i, j]];
165        }
166        for j in 0..lag_const.ncols() {
167            design[[i, k_old + j]] = lag_const[[i, j]];
168        }
169    }
170    // xshort = last `nobs` of the padded series = the original residuals.
171    let yshort = resid.clone();
172    let res = LinearModel::ols(yshort, design)?.fit()?;
173    let lm = nobs as f64 * res.rsquared;
174    let lm_pvalue = chi2_sf(lm, k as f64);
175
176    // F test: the final `k` coefficients (the residual lags) are jointly zero.
177    let mut r_mat = Array2::<f64>::zeros((k, k_vars));
178    for i in 0..k {
179        r_mat[[i, k_vars - k + i]] = 1.0;
180    }
181    let (fvalue, f_pvalue) = wald_test(&res, &r_mat, true)?;
182    Ok((lm, lm_pvalue, fvalue, f_pvalue))
183}
184
185/// Augmentation scheme for [`linear_reset`].
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum ResetAug {
188    /// Augment with powers of the fitted values `Xβ̂` (the default).
189    Fitted,
190    /// Augment with powers of the non-constant, non-binary columns of `exog`.
191    Exog,
192}
193
194/// Ramsey's RESET test for neglected nonlinearity.
195///
196/// Refits the model with the design augmented by powers `2, …, power` of either
197/// the original fitted values (`ResetAug::Fitted`) or the non-constant,
198/// non-binary `exog` columns (`ResetAug::Exog`), then performs a Wald test of
199/// the null that all added coefficients are zero. With `use_f = false` the
200/// chi-squared form is returned; with `use_f = true` the F form. `power` must be
201/// `≥ 2` and `exog` must contain at least one non-constant column. Returns
202/// `(statistic, pvalue)`. Mirrors the reference `linear_reset`.
203pub fn linear_reset(
204    endog: &Array1<f64>,
205    exog: &Array2<f64>,
206    power: usize,
207    test_type: ResetAug,
208    use_f: bool,
209) -> Result<(f64, f64)> {
210    if power < 2 {
211        return Err(Error::Value("power must be >= 2".into()));
212    }
213    let (n, k) = exog.dim();
214    if endog.len() != n {
215        return Err(Error::Shape("endog length must equal exog rows".into()));
216    }
217
218    // Columns to be raised to powers.
219    let aug_base: Array2<f64> = match test_type {
220        ResetAug::Fitted => {
221            let res = LinearModel::ols(endog.clone(), exog.clone())?.fit()?;
222            // n × 1 column of fitted values.
223            let mut a = Array2::<f64>::zeros((n, 1));
224            for i in 0..n {
225                a[[i, 0]] = res.fittedvalues[i];
226            }
227            a
228        }
229        ResetAug::Exog => {
230            // Drop constant and binary columns (a column is binary if every
231            // entry equals the column max or the column min).
232            let mut keep: Vec<usize> = Vec::new();
233            for j in 0..k {
234                let col = exog.column(j);
235                let mut mx = f64::NEG_INFINITY;
236                let mut mn = f64::INFINITY;
237                for &v in col.iter() {
238                    mx = mx.max(v);
239                    mn = mn.min(v);
240                }
241                let binary = col.iter().all(|&v| v == mx || v == mn);
242                if !binary {
243                    keep.push(j);
244                }
245            }
246            if keep.is_empty() {
247                return Err(Error::Value(
248                    "model contains only constant or binary data".into(),
249                ));
250            }
251            let mut a = Array2::<f64>::zeros((n, keep.len()));
252            for (cc, &j) in keep.iter().enumerate() {
253                for i in 0..n {
254                    a[[i, cc]] = exog[[i, j]];
255                }
256            }
257            a
258        }
259    };
260    let base_cols = aug_base.ncols();
261    let powers: Vec<usize> = (2..=power).collect();
262    let nrestr = base_cols * powers.len();
263
264    // Augmented design: exog | base^2 | base^3 | … | base^power.
265    let k_full = k + nrestr;
266    let mut design = Array2::<f64>::zeros((n, k_full));
267    for i in 0..n {
268        for j in 0..k {
269            design[[i, j]] = exog[[i, j]];
270        }
271    }
272    let mut col = k;
273    for &p in &powers {
274        for bc in 0..base_cols {
275            for i in 0..n {
276                design[[i, col]] = aug_base[[i, bc]].powi(p as i32);
277            }
278            col += 1;
279        }
280    }
281
282    let res = LinearModel::ols(endog.clone(), design)?.fit()?;
283    // Restriction: the last `nrestr` coefficients are jointly zero.
284    let mut r_mat = Array2::<f64>::zeros((nrestr, k_full));
285    for i in 0..nrestr {
286        r_mat[[i, k_full - nrestr + i]] = 1.0;
287    }
288    wald_test(&res, &r_mat, use_f)
289}
290
291/// Likelihood-ratio comparison of a restricted (nested) OLS fit against the
292/// full fit.
293///
294/// Returns `(lr_stat, p_value, df_diff)` where
295/// `lr_stat = −2 (llf_restricted − llf_full)` is chi-squared distributed with
296/// `df_diff = df_resid_restricted − df_resid_full` degrees of freedom. The
297/// restricted model must be nested in `full`. Mirrors the reference
298/// `compare_lr_test`.
299pub fn compare_lr_test(full: &LinearResults, restricted: &LinearResults) -> (f64, f64, f64) {
300    let lrdf = restricted.df_resid - full.df_resid;
301    let lrstat = -2.0 * (restricted.llf - full.llf);
302    let lr_pvalue = chi2_sf(lrstat, lrdf);
303    (lrstat, lr_pvalue, lrdf)
304}
305
306/// F-test comparison of a restricted (nested) OLS fit against the full fit.
307///
308/// Returns `(f_value, p_value, df_diff)` where
309/// `f_value = (ssr_restricted − ssr_full) / df_diff / ssr_full · df_resid_full`
310/// is `F(df_diff, df_resid_full)` distributed and
311/// `df_diff = df_resid_restricted − df_resid_full`. The restricted model must be
312/// nested in `full`. Mirrors the reference `compare_f_test`.
313pub fn compare_f_test(full: &LinearResults, restricted: &LinearResults) -> (f64, f64, f64) {
314    let df_full = full.df_resid;
315    let df_diff = restricted.df_resid - df_full;
316    let f_value = (restricted.ssr - full.ssr) / df_diff / full.ssr * df_full;
317    let p_value = f_sf(f_value, df_diff, df_full);
318    (f_value, p_value, df_diff)
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use ndarray::array;
325
326    fn small_ols() -> (Array1<f64>, Array2<f64>) {
327        let x = array![
328            [1.0, 0.2, -0.5],
329            [1.0, -0.1, 0.3],
330            [1.0, 0.4, 0.1],
331            [1.0, -0.3, -0.2],
332            [1.0, 0.5, 0.6],
333            [1.0, -0.2, -0.4],
334            [1.0, 0.1, 0.2],
335            [1.0, 0.3, -0.1],
336            [1.0, -0.4, 0.5],
337            [1.0, 0.0, -0.3],
338            [1.0, 0.25, 0.15],
339            [1.0, -0.35, 0.05],
340        ];
341        let y = array![0.9, 1.1, 1.4, 0.7, 1.8, 0.6, 1.2, 1.0, 0.8, 1.05, 1.3, 0.95];
342        (y, x)
343    }
344
345    #[test]
346    fn lagmat_both_shape_and_values() {
347        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
348        let m = lagmat_both(&x, 2);
349        assert_eq!(m.dim(), (3, 2));
350        // row 0 corresponds to current index 2: [x1, x0] = [2, 1]
351        assert_eq!(m[[0, 0]], 2.0);
352        assert_eq!(m[[0, 1]], 1.0);
353        assert_eq!(m[[2, 0]], 4.0);
354        assert_eq!(m[[2, 1]], 3.0);
355    }
356
357    #[test]
358    fn acorr_lm_runs_and_bounds() {
359        let (y, x) = small_ols();
360        let res = LinearModel::ols(y, x).unwrap().fit().unwrap();
361        let (lm, p, f, fp) = acorr_lm(&res.resid, Some(2), 0).unwrap();
362        assert!(lm >= 0.0);
363        assert!((0.0..=1.0).contains(&p));
364        assert!(f >= 0.0);
365        assert!((0.0..=1.0).contains(&fp));
366    }
367
368    #[test]
369    fn het_arch_equals_acorr_lm_of_squares() {
370        let (y, x) = small_ols();
371        let res = LinearModel::ols(y, x).unwrap().fit().unwrap();
372        let a = het_arch(&res.resid, Some(2), 0).unwrap();
373        let b = acorr_lm(&res.resid.mapv(|v| v * v), Some(2), 0).unwrap();
374        assert!((a.0 - b.0).abs() < 1e-12);
375        assert!((a.2 - b.2).abs() < 1e-12);
376    }
377
378    #[test]
379    fn bg_and_reset_run() {
380        let (y, x) = small_ols();
381        let res = LinearModel::ols(y.clone(), x.clone())
382            .unwrap()
383            .fit()
384            .unwrap();
385        let (lm, p, _f, fp) = acorr_breusch_godfrey(&res.resid, &x, Some(2)).unwrap();
386        assert!(lm >= 0.0 && (0.0..=1.0).contains(&p) && (0.0..=1.0).contains(&fp));
387        let (stat, pv) = linear_reset(&y, &x, 3, ResetAug::Fitted, false).unwrap();
388        assert!(stat >= 0.0 && (0.0..=1.0).contains(&pv));
389    }
390
391    #[test]
392    fn compare_tests_nested() {
393        let (y, x) = small_ols();
394        let full = LinearModel::ols(y.clone(), x.clone())
395            .unwrap()
396            .fit()
397            .unwrap();
398        // Restricted: drop last column.
399        let xr = x.slice(ndarray::s![.., 0..2]).to_owned();
400        let restr = LinearModel::ols(y, xr).unwrap().fit().unwrap();
401        let (lr, lp, ldf) = compare_lr_test(&full, &restr);
402        let (f, fp, fdf) = compare_f_test(&full, &restr);
403        assert_eq!(ldf, 1.0);
404        assert_eq!(fdf, 1.0);
405        assert!(lr >= 0.0 && (0.0..=1.0).contains(&lp));
406        assert!(f >= 0.0 && (0.0..=1.0).contains(&fp));
407    }
408}