Skip to main content

greeners_diagnostics/
specification_tests.rs

1use greeners_core::linalg::LinalgInverse as _;
2use ndarray::{Array1, Array2};
3use statrs::distribution::{ChiSquared, ContinuousCDF, FisherSnedecor};
4
5/// Specification tests for regression models
6pub struct SpecificationTests;
7
8impl SpecificationTests {
9    /// White's Test for Heteroskedasticity
10    ///
11    /// Tests H₀: Homoskedasticity (constant variance) vs H₁: Heteroskedasticity
12    ///
13    /// # Arguments
14    /// * `residuals` - Residuals from OLS regression
15    /// * `x` - Design matrix (n × k)
16    ///
17    /// # Returns
18    /// Tuple of (LM_statistic, p_value, degrees_of_freedom)
19    ///
20    /// # Interpretation
21    /// - If p < 0.05: Reject H₀, heteroskedasticity is present (use robust SE)
22    /// - If p > 0.05: Fail to reject H₀, homoskedasticity is plausible
23    ///
24    /// ```rust
25    /// use greeners_diagnostics::specification_tests::SpecificationTests;
26    /// use ndarray::{Array1, Array2};
27    ///
28    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
29    /// # let residuals = Array1::from(vec![0.1, -0.2, 0.1, 0.0, 0.1]);
30    /// # let x = Array2::from_shape_vec((5, 2), vec![1., 1., 1., 2., 1., 3., 1., 4., 1., 5.])?;
31    /// // ... the rest of your original example here ...
32    /// let (lm_stat, p_value, df) = SpecificationTests::white_test(&residuals, &x)?;
33    /// # Ok(())
34    /// # }
35    /// ```
36    pub fn white_test(
37        residuals: &Array1<f64>,
38        x: &Array2<f64>,
39    ) -> Result<(f64, f64, usize), String> {
40        let n = residuals.len();
41        let k = x.ncols();
42
43        // Square residuals (dependent variable for auxiliary regression)
44        let u_squared = residuals.mapv(|r| r.powi(2));
45
46        // Create auxiliary regressors: x and x² (simplified to avoid singularity)
47        // Exclude constant term (first column) to avoid perfect multicollinearity
48        let mut aux_regressors = Vec::new();
49
50        // Add constant term
51        aux_regressors.push(x.column(0).to_owned());
52
53        // Add non-constant regressors and their squares (skip first column = constant)
54        for j in 1..k {
55            aux_regressors.push(x.column(j).to_owned());
56        }
57
58        // Add squared terms for non-constant regressors
59        for j in 1..k {
60            let x_j = x.column(j);
61            aux_regressors.push(x_j.mapv(|v| v.powi(2)));
62        }
63
64        let p = aux_regressors.len(); //Total number of auxiliary regressors
65
66        // Build auxiliary design matrix
67        let mut x_aux = Array2::<f64>::zeros((n, p));
68        for (j, regressor) in aux_regressors.iter().enumerate() {
69            x_aux.column_mut(j).assign(regressor);
70        }
71
72        // Auxiliary regression: u² = X_aux * β + error
73        // Calculate R² from this auxiliary regression
74        let x_t = x_aux.t();
75        let xtx = x_t.dot(&x_aux);
76        let xtx_inv: Array2<f64> = match xtx.inv() {
77            Ok(inv) => inv,
78            Err(_) => return Err("Singular matrix in White test auxiliary regression".to_string()),
79        };
80
81        let xty = x_t.dot(&u_squared);
82        let beta_aux: Array1<f64> = xtx_inv.dot(&xty);
83        let fitted: Array1<f64> = x_aux.dot(&beta_aux);
84
85        // Calculate R² for auxiliary regression
86        let mean_u_sq = u_squared.mean().unwrap_or(0.0);
87        let tss = u_squared
88            .iter()
89            .map(|&y| (y - mean_u_sq).powi(2))
90            .sum::<f64>();
91        let rss = fitted
92            .iter()
93            .zip(u_squared.iter())
94            .map(|(&f, &y)| (y - f).powi(2))
95            .sum::<f64>();
96        let r_squared = 1.0 - rss / tss;
97
98        // White's LM statistic: n * R²
99        let lm_stat = (n as f64) * r_squared;
100
101        // Degrees of freedom = number of auxiliary regressors (excluding constant if present)
102        let df = p;
103
104        // Under H₀, LM ~ χ²(df)
105        let chi2_dist = ChiSquared::new(df as f64).map_err(|e| e.to_string())?;
106        let p_value = 1.0 - chi2_dist.cdf(lm_stat);
107
108        Ok((lm_stat, p_value, df))
109    }
110
111    /// Ramsey RESET Test for Functional Form Misspecification
112    ///
113    /// Tests H₀: Model is correctly specified vs H₁: Functional form misspecification
114    ///
115    /// # Arguments
116    /// * `y` - Dependent variable
117    /// * `x` - Design matrix (n × k)
118    /// * `fitted_values` - Fitted values from original regression
119    /// * `power` - Maximum power of fitted values to include (typically 2, 3, or 4)
120    ///
121    /// # Returns
122    /// Tuple of (F_statistic, p_value, df_num, df_denom)
123    ///
124    /// # Interpretation
125    /// - If p < 0.05: Reject H₀, functional form misspecification detected
126    /// - If p > 0.05: Fail to reject H₀, functional form appears adequate
127    ///
128    /// ```rust
129    /// use greeners_diagnostics::specification_tests::SpecificationTests;
130    /// use ndarray::{Array1, Array2};
131    ///
132    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
133    /// # let y = Array1::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
134    /// # let x = Array2::from_shape_vec((5, 2), vec![1., 1., 1., 2., 1., 3., 1., 4., 1., 5.])?;
135    /// # let fitted = Array1::from(vec![1.1, 1.9, 3.1, 3.9, 5.1]);
136    /// let (f_stat, p_value, _, _) = SpecificationTests::reset_test(&y, &x, &fitted, 2)?;
137    /// # Ok(())
138    /// # }
139    /// ```
140    pub fn reset_test(
141        y: &Array1<f64>,
142        x: &Array2<f64>,
143        fitted_values: &Array1<f64>,
144        power: usize,
145    ) -> Result<(f64, f64, usize, usize), String> {
146        if power < 2 {
147            return Err("Power must be at least 2 for RESET test".to_string());
148        }
149
150        let n = y.len();
151        let _k = x.ncols();
152
153        // Original model SSR
154        let residuals_orig: Array1<f64> = y - fitted_values;
155        let ssr_orig = residuals_orig.dot(&residuals_orig);
156
157        // Augmented model: add ŷ², ŷ³, ..., ŷ^power
158        let mut x_augmented = x.to_owned();
159        for p in 2..=power {
160            let y_hat_p = fitted_values.mapv(|v| v.powi(p as i32));
161            let n_rows = x_augmented.nrows();
162            let n_cols = x_augmented.ncols();
163            let mut new_x = Array2::<f64>::zeros((n_rows, n_cols + 1));
164            new_x
165                .slice_mut(ndarray::s![.., 0..n_cols])
166                .assign(&x_augmented);
167            new_x.column_mut(n_cols).assign(&y_hat_p);
168            x_augmented = new_x;
169        }
170
171        // Estimate augmented model
172        let x_aug_t = x_augmented.t();
173        let xtx_aug = x_aug_t.dot(&x_augmented);
174        let xtx_aug_inv: Array2<f64> = match xtx_aug.inv() {
175            Ok(inv) => inv,
176            Err(_) => return Err("Singular matrix in RESET test".to_string()),
177        };
178
179        let xty_aug = x_aug_t.dot(y);
180        let beta_aug: Array1<f64> = xtx_aug_inv.dot(&xty_aug);
181        let fitted_aug: Array1<f64> = x_augmented.dot(&beta_aug);
182        let residuals_aug: Array1<f64> = y - &fitted_aug;
183        let ssr_aug = residuals_aug.dot(&residuals_aug);
184
185        // F-statistic
186        let q = power - 1; // Number of restrictions (added powers)
187        let df_num = q;
188        let df_denom = n - x_augmented.ncols();
189
190        // if df_denom <= 0 {
191        //     return Err("Insufficient degrees of freedom for RESET test".to_string());
192        // }
193
194        let f_stat = ((ssr_orig - ssr_aug) / df_num as f64) / (ssr_aug / df_denom as f64);
195
196        // P-value
197        let f_dist =
198            FisherSnedecor::new(df_num as f64, df_denom as f64).map_err(|e| e.to_string())?;
199        let p_value = 1.0 - f_dist.cdf(f_stat);
200
201        Ok((f_stat, p_value, df_num, df_denom))
202    }
203
204    /// Breusch-Godfrey Test for Autocorrelation
205    ///
206    /// Tests H₀: No autocorrelation up to lag p vs H₁: Autocorrelation present
207    ///
208    /// # Arguments
209    /// * `residuals` - Residuals from OLS regression
210    /// * `x` - Design matrix (n × k)
211    /// * `lags` - Number of lags to test (typically 1 for AR(1))
212    ///
213    /// # Returns
214    /// Tuple of (LM_statistic, p_value, degrees_of_freedom)
215    ///
216    /// # Interpretation
217    /// - If p < 0.05: Reject H₀, autocorrelation detected
218    /// - If p > 0.05: Fail to reject H₀, no evidence of autocorrelation
219    ///
220    /// # Examples
221    ///
222    /// ```rust
223    /// use greeners_diagnostics::specification_tests::SpecificationTests;
224    /// use ndarray::{Array1, Array2};
225    ///
226    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
227    /// # // Hidden setup for test run:
228    /// # let residuals = Array1::from(vec![0.1, -0.2, 0.1, 0.0, 0.1]);
229    /// # let x = Array2::from_shape_vec((5, 2), vec![1., 1., 1., 2., 1., 3., 1., 4., 1., 5.])?;
230    /// // The visible example starts here:
231    /// let (lm_stat, p_value, df) = SpecificationTests::breusch_godfrey_test(&residuals, &x, 1)?;
232    /// # Ok(())
233    /// # }
234    /// ```
235    pub fn breusch_godfrey_test(
236        residuals: &Array1<f64>,
237        x: &Array2<f64>,
238        lags: usize,
239    ) -> Result<(f64, f64, usize), String> {
240        let n = residuals.len();
241        let _k = x.ncols();
242
243        if lags >= n {
244            return Err("Number of lags must be less than sample size".to_string());
245        }
246
247        // Create lagged residuals matrix
248        let mut x_augmented = x.to_owned();
249        for lag in 1..=lags {
250            let mut lagged = Array1::<f64>::zeros(n);
251            for i in lag..n {
252                lagged[i] = residuals[i - lag];
253            }
254
255            // Append lagged residuals as new column
256            let n_rows = x_augmented.nrows();
257            let n_cols = x_augmented.ncols();
258            let mut new_x = Array2::<f64>::zeros((n_rows, n_cols + 1));
259            new_x
260                .slice_mut(ndarray::s![.., 0..n_cols])
261                .assign(&x_augmented);
262            new_x.column_mut(n_cols).assign(&lagged);
263            x_augmented = new_x;
264        }
265
266        // Drop first 'lags' observations to avoid zeros
267        let x_aug_trim = x_augmented.slice(ndarray::s![lags.., ..]).to_owned();
268        let u_trim = residuals.slice(ndarray::s![lags..]).to_owned();
269        let n_trim = u_trim.len();
270
271        // Auxiliary regression: u_t = X*β + γ₁*u_{t-1} + ... + γₚ*u_{t-p} + error
272        let x_aug_t = x_aug_trim.t();
273        let xtx_aug = x_aug_t.dot(&x_aug_trim);
274        let xtx_aug_inv: Array2<f64> = match xtx_aug.inv() {
275            Ok(inv) => inv,
276            Err(_) => return Err("Singular matrix in Breusch-Godfrey test".to_string()),
277        };
278
279        let xty_aug = x_aug_t.dot(&u_trim);
280        let beta_aug: Array1<f64> = xtx_aug_inv.dot(&xty_aug);
281        let fitted_aug: Array1<f64> = x_aug_trim.dot(&beta_aug);
282
283        // Calculate R² for auxiliary regression
284        let mean_u = u_trim.mean().unwrap_or(0.0);
285        let tss = u_trim.iter().map(|&u| (u - mean_u).powi(2)).sum::<f64>();
286        let rss = fitted_aug
287            .iter()
288            .zip(u_trim.iter())
289            .map(|(&f, &u)| (u - f).powi(2))
290            .sum::<f64>();
291        let r_squared = 1.0 - rss / tss;
292
293        // LM statistic: n * R²
294        let lm_stat = (n_trim as f64) * r_squared;
295
296        // Degrees of freedom = number of lags
297        let df = lags;
298
299        // Under H₀, LM ~ χ²(lags)
300        let chi2_dist = ChiSquared::new(df as f64).map_err(|e| e.to_string())?;
301        let p_value = 1.0 - chi2_dist.cdf(lm_stat);
302
303        Ok((lm_stat, p_value, df))
304    }
305
306    /// Goldfeld-Quandt Test for Heteroskedasticity
307    ///
308    /// Tests H₀: Homoskedasticity vs H₁: Variance increases with ordering variable
309    ///
310    /// # Arguments
311    /// * `residuals` - Residuals from OLS regression (should be ordered by suspected variable)
312    /// * `split_fraction` - Fraction of middle observations to drop (typically 0.2 to 0.33)
313    ///
314    /// # Returns
315    /// Tuple of (F_statistic, p_value, df1, df2)
316    ///
317    /// # Interpretation
318    /// - If p < 0.05: Reject H₀, heteroskedasticity detected
319    /// - If p > 0.05: Fail to reject H₀, homoskedasticity plausible
320    pub fn goldfeld_quandt_test(
321        residuals: &Array1<f64>,
322        split_fraction: f64,
323    ) -> Result<(f64, f64, usize, usize), String> {
324        let n = residuals.len();
325        let drop_n = (n as f64 * split_fraction) as usize;
326        let group_size = (n - drop_n) / 2;
327
328        if group_size < 2 {
329            return Err("Insufficient observations for Goldfeld-Quandt test".to_string());
330        }
331
332        // First group: observations 0 to group_size-1
333        let group1 = residuals.slice(ndarray::s![0..group_size]);
334        let ssr1: f64 = group1.iter().map(|&r| r.powi(2)).sum();
335
336        // Second group: observations n-group_size to n-1
337        let group2 = residuals.slice(ndarray::s![(n - group_size)..]);
338        let ssr2: f64 = group2.iter().map(|&r| r.powi(2)).sum();
339
340        // F-statistic: ratio of variances (larger / smaller)
341        let f_stat = if ssr2 > ssr1 {
342            ssr2 / ssr1
343        } else {
344            ssr1 / ssr2
345        };
346
347        let df1 = group_size;
348        let df2 = group_size;
349
350        // P-value (two-tailed)
351        let f_dist = FisherSnedecor::new(df1 as f64, df2 as f64).map_err(|e| e.to_string())?;
352        let p_value = 2.0 * (1.0 - f_dist.cdf(f_stat)).min(f_dist.cdf(f_stat));
353
354        Ok((f_stat, p_value, df1, df2))
355    }
356
357    /// Pretty print specification test results
358    pub fn print_test_result(
359        test_name: &str,
360        statistic: f64,
361        p_value: f64,
362        null_hypothesis: &str,
363        alternative_hypothesis: &str,
364    ) {
365        println!("\n{:=^80}", format!(" {} ", test_name));
366        println!("{:-^80}", "");
367        println!("H₀: {}", null_hypothesis);
368        println!("H₁: {}", alternative_hypothesis);
369        println!("\nTest Statistic: {:.4}", statistic);
370        println!("P-value: {:.6}", p_value);
371
372        if p_value < 0.01 {
373            println!("\n✅ REJECT H₀ at 1% level (p < 0.01)");
374            println!("   → Strong evidence for H₁");
375        } else if p_value < 0.05 {
376            println!("\n✅ REJECT H₀ at 5% level (p < 0.05)");
377            println!("   → Evidence for H₁");
378        } else if p_value < 0.10 {
379            println!("\n⚠️  MARGINALLY REJECT H₀ at 10% level (p < 0.10)");
380            println!("   → Weak evidence for H₁");
381        } else {
382            println!("\n❌ FAIL TO REJECT H₀ (p > 0.10)");
383            println!("   → No evidence against H₀");
384        }
385        println!("{:=^80}", "");
386    }
387}