Skip to main content

regression_diagnostics/
summary.rs

1//! [`Summary`] — a one-call, R/`statsmodels`-style diagnostic report.
2
3use std::fmt;
4
5use statrs::distribution::{ContinuousCDF, StudentsT};
6
7use crate::coefficients::standardized_coefficients;
8use crate::fit_statistics::{
9    adjusted_r_squared, aic, bic, f_statistic, log_likelihood, r_squared, FStatistic,
10};
11use crate::influence::cooks_distance;
12use crate::multicollinearity::{condition_number, vif};
13use crate::residuals::white_test;
14use crate::residuals::{
15    breusch_pagan, durbin_watson, jarque_bera, BreuschPagan, JarqueBera, WhiteTest,
16};
17use crate::OlsFit;
18
19/// One row of the coefficient table.
20#[derive(Debug, Clone, PartialEq)]
21pub struct CoefficientRow {
22    /// Display name (`const` for the intercept, otherwise `x1`, `x2`, …).
23    pub name: String,
24    /// Point estimate `βⱼ`.
25    pub estimate: f64,
26    /// Standard error `sⱼ`.
27    pub std_error: f64,
28    /// t statistic `βⱼ / sⱼ`.
29    pub t_value: f64,
30    /// Two-sided p-value under the t distribution with `n − p` degrees of freedom.
31    pub p_value: f64,
32    /// Standardized (beta) coefficient (`NaN` for the intercept).
33    pub std_coefficient: f64,
34    /// Variance Inflation Factor (`NaN` for the intercept).
35    pub vif: f64,
36}
37
38/// A structured snapshot of every diagnostic in the crate for one fit.
39///
40/// `Summary` is **structured data first**: every field is a real number you can
41/// read programmatically. The [`Display`](fmt::Display) impl is a convenience
42/// layer that renders it as a `statsmodels`-style table — it is not the primary
43/// interface, so you never have to parse text to get a value back out.
44///
45/// Build it with [`OlsFit::summary`].
46#[derive(Debug, Clone)]
47pub struct Summary {
48    /// Number of observations.
49    pub n_observations: usize,
50    /// Number of parameters.
51    pub n_parameters: usize,
52    /// Residual degrees of freedom.
53    pub df_residual: f64,
54    /// Whether an intercept is present.
55    pub has_intercept: bool,
56    /// One row per coefficient.
57    pub coefficients: Vec<CoefficientRow>,
58    /// `R²`.
59    pub r_squared: f64,
60    /// Adjusted `R²`.
61    pub adj_r_squared: f64,
62    /// Overall F-test.
63    pub f_statistic: FStatistic,
64    /// Residual standard error `s`.
65    pub residual_std_error: f64,
66    /// Gaussian log-likelihood.
67    pub log_likelihood: f64,
68    /// Akaike information criterion.
69    pub aic: f64,
70    /// Bayesian information criterion.
71    pub bic: f64,
72    /// Design-matrix condition number.
73    pub condition_number: f64,
74    /// Durbin-Watson statistic.
75    pub durbin_watson: f64,
76    /// Jarque-Bera normality test.
77    pub jarque_bera: JarqueBera,
78    /// Breusch-Pagan heteroskedasticity test.
79    pub breusch_pagan: BreuschPagan,
80    /// White's heteroskedasticity test.
81    pub white: WhiteTest,
82}
83
84impl OlsFit {
85    /// Compute the full [`Summary`] for this fit — every statistic in Milestones
86    /// 2–6 in one call.
87    ///
88    /// ```
89    /// use ndarray::array;
90    /// use regression_diagnostics::OlsFit;
91    ///
92    /// let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0], [1.0, 4.0]];
93    /// let y = array![1.0, 3.1, 4.9, 7.0, 9.1];
94    /// let fit = OlsFit::new(x, y).unwrap();
95    /// let s = fit.summary();
96    /// println!("{s}");
97    /// assert!(s.r_squared > 0.99);
98    /// ```
99    pub fn summary(&self) -> Summary {
100        let se = self.coefficient_standard_errors();
101        let coef = self.coefficients();
102        let vifs = vif(self);
103        let std_coefs = standardized_coefficients(self);
104        let df = self.df_residual();
105        let t_dist = StudentsT::new(0.0, 1.0, df).ok();
106
107        let mut predictor_counter = 0usize;
108        let coefficients = (0..self.n_parameters())
109            .map(|j| {
110                let name = if self.intercept_column() == Some(j) {
111                    "const".to_string()
112                } else {
113                    predictor_counter += 1;
114                    format!("x{predictor_counter}")
115                };
116                let est = coef[j];
117                let s = se[j];
118                let t = if s > 0.0 { est / s } else { f64::NAN };
119                let p = match &t_dist {
120                    Some(d) if t.is_finite() => 2.0 * (1.0 - d.cdf(t.abs())),
121                    _ => f64::NAN,
122                };
123                CoefficientRow {
124                    name,
125                    estimate: est,
126                    std_error: s,
127                    t_value: t,
128                    p_value: p,
129                    std_coefficient: std_coefs[j],
130                    vif: vifs[j],
131                }
132            })
133            .collect();
134
135        Summary {
136            n_observations: self.n_observations(),
137            n_parameters: self.n_parameters(),
138            df_residual: df,
139            has_intercept: self.has_intercept(),
140            coefficients,
141            r_squared: r_squared(self),
142            adj_r_squared: adjusted_r_squared(self),
143            f_statistic: f_statistic(self),
144            residual_std_error: self.residual_standard_error(),
145            log_likelihood: log_likelihood(self),
146            aic: aic(self),
147            bic: bic(self),
148            condition_number: condition_number(self),
149            durbin_watson: durbin_watson(self),
150            jarque_bera: jarque_bera(self),
151            breusch_pagan: breusch_pagan(self),
152            white: white_test(self),
153        }
154    }
155}
156
157impl Summary {
158    /// Maximum absolute Cook's distance across observations, recomputed from the
159    /// fit — a convenience for callers that want a single influence headline
160    /// number without walking the full vector. (Not stored on `Summary` because
161    /// it is per-observation; use [`crate::influence::cooks_distance`] for the
162    /// full vector.)
163    pub fn max_cooks_distance(fit: &OlsFit) -> f64 {
164        cooks_distance(fit)
165            .iter()
166            .copied()
167            .filter(|v| v.is_finite())
168            .fold(0.0_f64, f64::max)
169    }
170}
171
172fn flag(p: f64, low: f64, high: f64, hi_is_bad: bool) -> &'static str {
173    if p.is_nan() {
174        return "";
175    }
176    let bad = if hi_is_bad { p > high } else { p < low };
177    if bad {
178        " (!)"
179    } else {
180        ""
181    }
182}
183
184impl fmt::Display for Summary {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        writeln!(f, "{:=^78}", " OLS Diagnostics ")?;
187        writeln!(
188            f,
189            "No. Observations: {:>6}    Df Residuals: {:>6}    Df Model: {:>6}",
190            self.n_observations,
191            self.df_residual as usize,
192            self.n_parameters - usize::from(self.has_intercept),
193        )?;
194        writeln!(
195            f,
196            "R-squared:        {:>8.4}  Adj. R-squared: {:>8.4}  Resid. SE: {:>8.4}",
197            self.r_squared, self.adj_r_squared, self.residual_std_error,
198        )?;
199        writeln!(
200            f,
201            "F-statistic:      {:>8.4}  Prob(F):        {:>8.4}  Log-Lik:   {:>8.2}",
202            self.f_statistic.statistic, self.f_statistic.p_value, self.log_likelihood,
203        )?;
204        writeln!(
205            f,
206            "AIC:              {:>8.2}  BIC:            {:>8.2}  Cond. No.: {:>8.3e}",
207            self.aic, self.bic, self.condition_number,
208        )?;
209
210        writeln!(f, "{:-<78}", "")?;
211        writeln!(
212            f,
213            "{:<8}{:>12}{:>11}{:>9}{:>9}{:>9}{:>9}",
214            "", "coef", "std err", "t", "P>|t|", "beta", "VIF",
215        )?;
216        writeln!(f, "{:-<78}", "")?;
217        for row in &self.coefficients {
218            let beta = if row.std_coefficient.is_nan() {
219                "     -   ".to_string()
220            } else {
221                format!("{:>9.3}", row.std_coefficient)
222            };
223            let vif = if row.vif.is_nan() {
224                "     -   ".to_string()
225            } else if row.vif.is_infinite() {
226                "      inf".to_string()
227            } else {
228                format!("{:>9.2}", row.vif)
229            };
230            writeln!(
231                f,
232                "{:<8}{:>12.4}{:>11.4}{:>9.3}{:>9.3}{beta}{vif}",
233                row.name, row.estimate, row.std_error, row.t_value, row.p_value,
234            )?;
235        }
236        writeln!(f, "{:-<78}", "")?;
237
238        writeln!(
239            f,
240            "Durbin-Watson:    {:>8.4}   (residual autocorrelation; ~2 is ideal)",
241            self.durbin_watson,
242        )?;
243        writeln!(
244            f,
245            "Jarque-Bera:      {:>8.4}   Prob: {:>7.4}{}   (skew {:.3}, kurt {:.3})",
246            self.jarque_bera.statistic,
247            self.jarque_bera.p_value,
248            flag(self.jarque_bera.p_value, 0.05, 0.0, false),
249            self.jarque_bera.skewness,
250            self.jarque_bera.kurtosis,
251        )?;
252        writeln!(
253            f,
254            "Breusch-Pagan:    {:>8.4}   Prob: {:>7.4}{}   (heteroskedasticity, LM)",
255            self.breusch_pagan.statistic,
256            self.breusch_pagan.p_value,
257            flag(self.breusch_pagan.p_value, 0.05, 0.0, false),
258        )?;
259        writeln!(
260            f,
261            "White:            {:>8.4}   Prob: {:>7.4}{}   (heteroskedasticity, general)",
262            self.white.statistic,
263            self.white.p_value,
264            flag(self.white.p_value, 0.05, 0.0, false),
265        )?;
266        write!(f, "{:=<78}", "")?;
267        Ok(())
268    }
269}