regression_diagnostics/fit.rs
1//! [`OlsFit`] — the fitted-model type every diagnostic in this crate operates
2//! on.
3
4use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
5
6use crate::error::{RegressionError, Result};
7use crate::linalg::{self, dmatrix_from_rows, dvector_from_slice};
8
9/// A fitted ordinary-least-squares model: the shared representation every
10/// diagnostic in this crate is computed from.
11///
12/// # Intercept convention
13///
14/// **The caller owns the design matrix `X`, including any intercept column.**
15/// This crate does *not* silently prepend a column of ones. There are two
16/// constructors, and the choice is explicit at the call site:
17///
18/// * [`OlsFit::new`] fits exactly the columns you pass. If one of them is
19/// constant it is auto-detected and treated as the intercept (this drives the
20/// centered-vs-uncentered `R²` choice, and excludes that column from VIF); if
21/// none is constant the model is fit **through the origin**.
22/// * [`OlsFit::with_intercept`] prepends a ones column for you and marks it as
23/// the intercept.
24///
25/// Getting this wrong silently corrupts every downstream diagnostic, so the
26/// convention is stated here rather than buried in the implementation.
27///
28/// # What is computed and cached
29///
30/// Construction performs a single QR factorization of `X` and caches the
31/// coefficients, fitted values, residuals, leverage vector `diag(H)`, `(XᵀX)⁻¹`,
32/// the design's singular values, and the residual variance. Diagnostics read
33/// these cached quantities rather than refactorizing.
34///
35/// Coefficients are obtained by a QR **solve of `X`**, never by inverting `XᵀX`
36/// — that matters for the multicollinearity diagnostics specifically, since
37/// forming `XᵀX` squares the condition number they exist to measure. Leverage is
38/// read from the thin `Q` factor, so the full `n × n` hat matrix is never formed.
39///
40/// # Example
41///
42/// ```
43/// use ndarray::{array, Array2};
44/// use regression_diagnostics::OlsFit;
45///
46/// // y = 1 + 2*x exactly; supply the intercept ourselves.
47/// let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
48/// let y = array![1.0, 3.0, 5.0, 7.0];
49/// let fit = OlsFit::new(x, y).unwrap();
50/// assert!((fit.coefficients()[0] - 1.0).abs() < 1e-9);
51/// assert!((fit.coefficients()[1] - 2.0).abs() < 1e-9);
52/// ```
53#[derive(Debug, Clone)]
54pub struct OlsFit {
55 x: Array2<f64>,
56 y: Array1<f64>,
57 coefficients: Array1<f64>,
58 fitted: Array1<f64>,
59 residuals: Array1<f64>,
60 leverage: Array1<f64>,
61 xtx_inv: Array2<f64>,
62 singular_values: Vec<f64>,
63 intercept_col: Option<usize>,
64 n: usize,
65 p: usize,
66 rss: f64,
67 /// Residual variance estimate `RSS / (n - p)`.
68 sigma2: f64,
69}
70
71impl OlsFit {
72 /// Fit `y ~ X` by OLS, using the columns of `X` exactly as given.
73 ///
74 /// A constant column, if present, is auto-detected as the intercept. See the
75 /// [type-level docs](OlsFit#intercept-convention) for the full convention.
76 ///
77 /// # Errors
78 ///
79 /// * [`RegressionError::EmptyInput`] if `X` or `y` is empty.
80 /// * [`RegressionError::ShapeMismatch`] if `X.nrows() != y.len()`.
81 /// * [`RegressionError::NoResidualDegreesOfFreedom`] if `n <= p`.
82 /// * [`RegressionError::RankDeficient`] if the columns are collinear.
83 pub fn new(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
84 let intercept_col = detect_constant_column(&x);
85 Self::build(x, y, intercept_col)
86 }
87
88 /// Fit `y ~ [1 | X]` by OLS, prepending a column of ones as the intercept.
89 ///
90 /// Errors are the same as [`OlsFit::new`], evaluated against the augmented
91 /// design (so a design with `n == p_original + 1` will fail the
92 /// residual-degrees-of-freedom check).
93 pub fn with_intercept(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
94 if x.nrows() == 0 {
95 return Err(RegressionError::EmptyInput { what: "X" });
96 }
97 let n = x.nrows();
98 let mut augmented = Array2::<f64>::ones((n, x.ncols() + 1));
99 for j in 0..x.ncols() {
100 augmented.column_mut(j + 1).assign(&x.column(j));
101 }
102 Self::build(augmented, y, Some(0))
103 }
104
105 fn build(x: Array2<f64>, y: Array1<f64>, intercept_col: Option<usize>) -> Result<Self> {
106 let n = x.nrows();
107 let p = x.ncols();
108 if n == 0 || p == 0 {
109 return Err(RegressionError::EmptyInput { what: "X" });
110 }
111 if y.is_empty() {
112 return Err(RegressionError::EmptyInput { what: "y" });
113 }
114 if y.len() != n {
115 return Err(RegressionError::ShapeMismatch {
116 what: "y length vs X rows",
117 expected: n,
118 got: y.len(),
119 });
120 }
121 if n <= p {
122 return Err(RegressionError::NoResidualDegreesOfFreedom {
123 n,
124 p,
125 df: n as isize - p as isize,
126 });
127 }
128
129 let x_dm = dmatrix_from_rows(
130 n,
131 p,
132 x.as_standard_layout().as_slice().expect("standard layout"),
133 );
134 let y_dv = dvector_from_slice(y.as_standard_layout().as_slice().expect("standard layout"));
135
136 let qr = linalg::ols_via_qr(&x_dm, &y_dv)?;
137
138 let coefficients = Array1::from_iter(qr.coef.iter().copied());
139 let fitted = Array1::from_iter(qr.fitted.iter().copied());
140 let residuals = &y - &fitted;
141 let leverage = Array1::from_iter(qr.leverage.iter().copied());
142 let xtx_inv = Array2::from_shape_fn((p, p), |(i, j)| qr.xtx_inv[(i, j)]);
143
144 let rss: f64 = residuals.iter().map(|r| r * r).sum();
145 let sigma2 = rss / (n - p) as f64;
146
147 Ok(Self {
148 x,
149 y,
150 coefficients,
151 fitted,
152 residuals,
153 leverage,
154 xtx_inv,
155 singular_values: qr.singular_values,
156 intercept_col,
157 n,
158 p,
159 rss,
160 sigma2,
161 })
162 }
163
164 // ---- basic accessors --------------------------------------------------
165
166 /// Number of observations `n`.
167 pub fn n_observations(&self) -> usize {
168 self.n
169 }
170
171 /// Number of model parameters `p` (design-matrix columns, intercept
172 /// included if present).
173 pub fn n_parameters(&self) -> usize {
174 self.p
175 }
176
177 /// Whether the model includes an intercept (constant) column.
178 pub fn has_intercept(&self) -> bool {
179 self.intercept_col.is_some()
180 }
181
182 /// Index of the intercept column within the design matrix, if any.
183 pub fn intercept_column(&self) -> Option<usize> {
184 self.intercept_col
185 }
186
187 /// The design matrix `X` as fitted (with the intercept column if one was
188 /// added or detected).
189 pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
190 self.x.view()
191 }
192
193 /// The response vector `y`.
194 pub fn response(&self) -> ArrayView1<'_, f64> {
195 self.y.view()
196 }
197
198 /// Estimated coefficients, one per design-matrix column.
199 pub fn coefficients(&self) -> ArrayView1<'_, f64> {
200 self.coefficients.view()
201 }
202
203 /// Fitted values `ŷ = X β`.
204 pub fn fitted_values(&self) -> ArrayView1<'_, f64> {
205 self.fitted.view()
206 }
207
208 /// Raw residuals `e = y − ŷ`.
209 pub fn residuals(&self) -> ArrayView1<'_, f64> {
210 self.residuals.view()
211 }
212
213 /// Leverage vector `diag(H)`. Each entry lies in `[0, 1]` and the vector
214 /// sums to `p` (the number of parameters) — a standard identity worth
215 /// checking as a correctness probe.
216 pub fn leverage(&self) -> ArrayView1<'_, f64> {
217 self.leverage.view()
218 }
219
220 /// Residual sum of squares `Σ eᵢ²`.
221 pub fn residual_sum_of_squares(&self) -> f64 {
222 self.rss
223 }
224
225 /// Residual degrees of freedom `n − p`.
226 pub fn df_residual(&self) -> f64 {
227 (self.n - self.p) as f64
228 }
229
230 /// Model degrees of freedom: `p − 1` with an intercept, `p` without.
231 pub fn df_model(&self) -> f64 {
232 if self.has_intercept() {
233 (self.p - 1) as f64
234 } else {
235 self.p as f64
236 }
237 }
238
239 /// Unbiased residual variance estimate `s² = RSS / (n − p)`.
240 pub fn residual_variance(&self) -> f64 {
241 self.sigma2
242 }
243
244 /// Residual standard error `s = √(RSS / (n − p))`.
245 pub fn residual_standard_error(&self) -> f64 {
246 self.sigma2.sqrt()
247 }
248
249 /// Standard errors of the coefficients: `sⱼ = s · √((XᵀX)⁻¹ⱼⱼ)`.
250 pub fn coefficient_standard_errors(&self) -> Array1<f64> {
251 Array1::from_shape_fn(self.p, |j| (self.sigma2 * self.xtx_inv[(j, j)]).sqrt())
252 }
253
254 /// Singular values of the design matrix, in descending order.
255 pub fn singular_values(&self) -> &[f64] {
256 &self.singular_values
257 }
258
259 // ---- crate-internal helpers ------------------------------------------
260
261 /// `R²` from regressing design column `j` on all other columns — the
262 /// auxiliary regression VIF is built on. Returns `None` if `j` is the
263 /// intercept column (VIF is undefined there).
264 pub(crate) fn column_on_others_r2(&self, j: usize) -> Option<f64> {
265 if self.intercept_col == Some(j) {
266 return None;
267 }
268 let others: Vec<usize> = (0..self.p).filter(|&c| c != j).collect();
269 let sub = self.x.select(ndarray::Axis(1), &others);
270 let target = self.x.column(j).to_owned();
271
272 let sub_dm = dmatrix_from_rows(
273 self.n,
274 others.len(),
275 sub.as_standard_layout()
276 .as_slice()
277 .expect("standard layout"),
278 );
279 let target_dv = dvector_from_slice(target.as_slice().expect("contiguous"));
280 // aux_r_squared returns Some(1.0) on a collinear auxiliary design →
281 // caller maps that to an infinite VIF.
282 linalg::aux_r_squared(&sub_dm, &target_dv)
283 }
284}
285
286/// Detect the first constant column of `x` (all entries equal within a relative
287/// tolerance), which is treated as an intercept.
288fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
289 for (j, col) in x.columns().into_iter().enumerate() {
290 let first = col[0];
291 let scale = first.abs().max(1.0);
292 if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
293 return Some(j);
294 }
295 }
296 None
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use ndarray::array;
303
304 /// Hand-verified closed-form OLS on a tiny fixed dataset.
305 ///
306 /// With intercept, x = [0,1,2,3], y = [1,3,5,7]: the exact least-squares line
307 /// is y = 1 + 2x (a perfect fit), so β = (1, 2), residuals are all zero.
308 #[test]
309 fn coefficients_match_closed_form() {
310 let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
311 let y = array![1.0, 3.0, 5.0, 7.0];
312 let fit = OlsFit::new(x, y).unwrap();
313 assert!((fit.coefficients()[0] - 1.0).abs() < 1e-9);
314 assert!((fit.coefficients()[1] - 2.0).abs() < 1e-9);
315 assert!(fit.residuals().iter().all(|&e| e.abs() < 1e-9));
316 }
317
318 /// Non-perfect fit, closed form checked by hand.
319 ///
320 /// x = [1,2,3,4,5], y = [1,2,1.3,3.75,2.25]. Standard OLS gives
321 /// slope ≈ 0.425, intercept ≈ 0.785 (classic worked example).
322 #[test]
323 fn coefficients_match_worked_example() {
324 let x = array![[1.0, 1.0], [1.0, 2.0], [1.0, 3.0], [1.0, 4.0], [1.0, 5.0]];
325 let y = array![1.0, 2.0, 1.3, 3.75, 2.25];
326 let fit = OlsFit::new(x, y).unwrap();
327 assert!((fit.coefficients()[0] - 0.785).abs() < 1e-3);
328 assert!((fit.coefficients()[1] - 0.425).abs() < 1e-3);
329 }
330
331 /// Leverage must sum to the number of parameters — a strong correctness
332 /// probe independent of the coefficient values.
333 #[test]
334 fn leverage_sums_to_p() {
335 let x = array![
336 [1.0, 0.0, 2.0],
337 [1.0, 1.0, 1.0],
338 [1.0, 2.0, 4.0],
339 [1.0, 3.0, 1.0],
340 [1.0, 4.0, 5.0],
341 [1.0, 5.0, 2.0],
342 ];
343 let y = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
344 let fit = OlsFit::new(x, y).unwrap();
345 let total: f64 = fit.leverage().sum();
346 assert!((total - 3.0).abs() < 1e-9, "leverage sum = {total}");
347 // Every leverage lies in [0, 1].
348 assert!(fit
349 .leverage()
350 .iter()
351 .all(|&h| (0.0..=1.0 + 1e-9).contains(&h)));
352 }
353
354 /// `with_intercept` prepends a ones column and detects it as the intercept.
355 #[test]
356 fn with_intercept_prepends_ones() {
357 let x = array![[0.0], [1.0], [2.0], [3.0]];
358 let y = array![1.0, 3.0, 5.0, 7.0];
359 let fit = OlsFit::with_intercept(x, y).unwrap();
360 assert_eq!(fit.n_parameters(), 2);
361 assert_eq!(fit.intercept_column(), Some(0));
362 assert!((fit.coefficients()[0] - 1.0).abs() < 1e-9);
363 assert!((fit.coefficients()[1] - 2.0).abs() < 1e-9);
364 }
365}