Skip to main content

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}