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}