Skip to main content

regression_diagnostics/regularized/
ridge.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3use crate::error::{RegressionError, Result};
4use crate::linalg::dmatrix_from_rows;
5
6/// A fitted ridge-regression model and its diagnostics.
7///
8/// Ridge solves `min ‖y − Xβ‖² + λ‖β_pen‖²`. This type fits it in **closed form
9/// via the SVD** of the (centered) predictors, which is both numerically stable
10/// and hands us the singular values that every ridge diagnostic is expressed in.
11///
12/// # Intercept and scaling
13///
14/// If a constant column is detected it is treated as an **unpenalized
15/// intercept**: predictors and response are mean-centered, the penalty is
16/// applied only to the slopes, and the intercept is recovered from the means
17/// (the same convention as scikit-learn's `Ridge`). Ridge is not scale-invariant,
18/// so standardize predictors beforehand if that matters for your `λ`.
19///
20/// # What the diagnostics mean
21///
22/// The OLS notions still exist but take their ridge forms: leverage is the
23/// diagonal of `H_λ`, and the parameter count is the **effective degrees of
24/// freedom** `df(λ) = Σ dⱼ²/(dⱼ²+λ)` (plus one for the intercept), which slides
25/// smoothly from `p` at `λ = 0` down toward `1` as `λ → ∞`.
26#[derive(Debug, Clone)]
27pub struct RidgeFit {
28    x: Array2<f64>,
29    y: Array1<f64>,
30    lambda: f64,
31    coefficients: Array1<f64>,
32    fitted: Array1<f64>,
33    residuals: Array1<f64>,
34    leverage: Array1<f64>,
35    /// Singular values of the centered predictor matrix.
36    singular_values: Vec<f64>,
37    effective_df: f64,
38    rss: f64,
39    intercept_col: Option<usize>,
40    n: usize,
41    p: usize,
42}
43
44impl RidgeFit {
45    /// Fit ridge regression of `y` on `X` with penalty `lambda ≥ 0`.
46    ///
47    /// A constant column of `X`, if present, is auto-detected and used as an
48    /// unpenalized intercept (see the [type docs](RidgeFit#intercept-and-scaling)).
49    ///
50    /// # Errors
51    ///
52    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`] as
53    ///   for [`OlsFit`](crate::OlsFit).
54    /// * [`RegressionError::InvalidParameter`] if `lambda < 0`.
55    ///
56    /// Unlike OLS, ridge is well-defined even when `n ≤ p` or the predictors are
57    /// collinear (that is much of the point), so those are not errors here.
58    pub fn new(x: Array2<f64>, y: Array1<f64>, lambda: f64) -> Result<Self> {
59        if x.nrows() == 0 || x.ncols() == 0 {
60            return Err(RegressionError::EmptyInput { what: "X" });
61        }
62        if y.len() != x.nrows() {
63            return Err(RegressionError::ShapeMismatch {
64                what: "y length vs X rows",
65                expected: x.nrows(),
66                got: y.len(),
67            });
68        }
69        if lambda < 0.0 || lambda.is_nan() {
70            return Err(RegressionError::InvalidParameter {
71                msg: format!("ridge lambda must be >= 0, got {lambda}"),
72            });
73        }
74
75        let n = x.nrows();
76        let p = x.ncols();
77        let intercept_col = detect_constant_column(&x);
78
79        // Predictor (non-intercept) column indices.
80        let pred: Vec<usize> = (0..p).filter(|&j| Some(j) != intercept_col).collect();
81        let q = pred.len();
82
83        // Center predictors and response when there is an intercept; otherwise
84        // penalize everything on its raw scale.
85        let has_intercept = intercept_col.is_some();
86        let y_mean = if has_intercept {
87            y.sum() / n as f64
88        } else {
89            0.0
90        };
91        let mut x_means = vec![0.0; q];
92        if has_intercept {
93            for (k, &j) in pred.iter().enumerate() {
94                x_means[k] = x.column(j).sum() / n as f64;
95            }
96        }
97
98        // Build the centered predictor matrix (n × q) in row-major order.
99        let mut xc = vec![0.0; n * q];
100        for i in 0..n {
101            for (k, &j) in pred.iter().enumerate() {
102                xc[i * q + k] = x[(i, j)] - x_means[k];
103            }
104        }
105        let yc: Vec<f64> = (0..n).map(|i| y[i] - y_mean).collect();
106
107        // Thin SVD of the centered predictors: Xc = U S Vᵀ.
108        let xc_dm = dmatrix_from_rows(n, q, &xc);
109        let svd = xc_dm.svd(true, true);
110        let u = svd.u.ok_or(RegressionError::RankDeficient)?; // n × q
111        let s = svd.singular_values; // length min(n, q)
112        let v_t = svd.v_t.ok_or(RegressionError::RankDeficient)?; // (min) × q
113        let r = s.len();
114
115        // a = Uᵀ yc
116        let a: Vec<f64> = (0..r)
117            .map(|j| (0..n).map(|i| u[(i, j)] * yc[i]).sum::<f64>())
118            .collect();
119        // filter factors f_j = d_j / (d_j² + λ)
120        let filt: Vec<f64> = (0..r).map(|j| s[j] / (s[j] * s[j] + lambda)).collect();
121        // slopes β_p[k] = Σ_j V[k,j] f_j a_j = Σ_j v_t[j,k] f_j a_j
122        let mut slopes = vec![0.0; q];
123        for (k, slope) in slopes.iter_mut().enumerate() {
124            *slope = (0..r).map(|j| v_t[(j, k)] * filt[j] * a[j]).sum();
125        }
126
127        // Assemble coefficients aligned to the original columns.
128        let mut coefficients = Array1::<f64>::zeros(p);
129        for (k, &j) in pred.iter().enumerate() {
130            coefficients[j] = slopes[k];
131        }
132        if let Some(c) = intercept_col {
133            let intercept = y_mean - (0..q).map(|k| x_means[k] * slopes[k]).sum::<f64>();
134            coefficients[c] = intercept;
135        }
136
137        // Fitted / residuals from the original design.
138        let fitted = x.dot(&coefficients);
139        let residuals = &y - &fitted;
140        let rss: f64 = residuals.iter().map(|e| e * e).sum();
141
142        // Ridge leverage (diagonal of H_λ) and effective df.
143        // shrink_j = d_j² / (d_j² + λ)
144        let shrink: Vec<f64> = (0..r)
145            .map(|j| {
146                let d2 = s[j] * s[j];
147                d2 / (d2 + lambda)
148            })
149            .collect();
150        let base = if has_intercept { 1.0 / n as f64 } else { 0.0 };
151        let base_df = if has_intercept { 1.0 } else { 0.0 };
152        let leverage = Array1::from_shape_fn(n, |i| {
153            base + (0..r)
154                .map(|j| u[(i, j)] * u[(i, j)] * shrink[j])
155                .sum::<f64>()
156        });
157        let effective_df = base_df + shrink.iter().sum::<f64>();
158
159        Ok(Self {
160            x,
161            y,
162            lambda,
163            coefficients,
164            fitted,
165            residuals,
166            leverage,
167            singular_values: s.iter().copied().collect(),
168            effective_df,
169            rss,
170            intercept_col,
171            n,
172            p,
173        })
174    }
175
176    /// The penalty `λ` this model was fit with.
177    pub fn lambda(&self) -> f64 {
178        self.lambda
179    }
180
181    /// Number of observations.
182    pub fn n_observations(&self) -> usize {
183        self.n
184    }
185
186    /// Number of coefficients (design columns, intercept included).
187    pub fn n_parameters(&self) -> usize {
188        self.p
189    }
190
191    /// Whether an intercept (constant column) is present and unpenalized.
192    pub fn has_intercept(&self) -> bool {
193        self.intercept_col.is_some()
194    }
195
196    /// The design matrix as fitted.
197    pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
198        self.x.view()
199    }
200
201    /// The response vector.
202    pub fn response(&self) -> ArrayView1<'_, f64> {
203        self.y.view()
204    }
205
206    /// Singular values of the centered predictor matrix — the `dⱼ` that the
207    /// shrinkage factors `dⱼ²/(dⱼ²+λ)` and the effective degrees of freedom are
208    /// expressed in.
209    pub fn singular_values(&self) -> &[f64] {
210        &self.singular_values
211    }
212
213    /// Ridge coefficients, aligned to the design columns.
214    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
215        self.coefficients.view()
216    }
217
218    /// Fitted values `ŷ = Xβ`.
219    pub fn fitted_values(&self) -> ArrayView1<'_, f64> {
220        self.fitted.view()
221    }
222
223    /// Residuals `y − ŷ`.
224    pub fn residuals(&self) -> ArrayView1<'_, f64> {
225        self.residuals.view()
226    }
227
228    /// Residual sum of squares.
229    pub fn residual_sum_of_squares(&self) -> f64 {
230        self.rss
231    }
232
233    /// Ridge leverage — the diagonal of `H_λ = X(XᵀX + λI)⁻¹Xᵀ` — computed from
234    /// the SVD without ever forming the `n × n` hat matrix.
235    ///
236    /// Unlike OLS leverage these do **not** sum to the number of columns; they
237    /// sum to the effective degrees of freedom [`effective_df`](Self::effective_df),
238    /// which is the ridge analogue of that identity.
239    pub fn leverage(&self) -> ArrayView1<'_, f64> {
240        self.leverage.view()
241    }
242
243    /// Effective degrees of freedom `df(λ) = base + Σ dⱼ²/(dⱼ²+λ)`, where `base`
244    /// is `1` for the unpenalized intercept (else `0`).
245    ///
246    /// This is the parameter count ridge actually spends: it equals `p` at
247    /// `λ = 0` and shrinks toward `1` (just the intercept) as `λ → ∞`. It drives
248    /// the effective residual df and the information criteria below.
249    pub fn effective_df(&self) -> f64 {
250        self.effective_df
251    }
252
253    /// Effective residual degrees of freedom `n − df(λ)`.
254    pub fn effective_residual_df(&self) -> f64 {
255        self.n as f64 - self.effective_df
256    }
257
258    /// Effective residual variance estimate `RSS / (n − df(λ))`.
259    pub fn residual_variance(&self) -> f64 {
260        self.rss / self.effective_residual_df()
261    }
262
263    /// Generalized Cross-Validation score
264    /// `GCV(λ) = (RSS / n) / (1 − df(λ)/n)²`.
265    ///
266    /// A rotation-invariant approximation to leave-one-out CV; the `λ` minimizing
267    /// it is a standard, data-driven penalty choice (see
268    /// [`select_lambda_gcv`]).
269    pub fn gcv(&self) -> f64 {
270        let denom = 1.0 - self.effective_df / self.n as f64;
271        if denom <= 0.0 {
272            return f64::INFINITY;
273        }
274        (self.rss / self.n as f64) / (denom * denom)
275    }
276
277    /// Gaussian log-likelihood at the fitted residual variance (same form as the
278    /// OLS log-likelihood, using `RSS` from the ridge fit).
279    pub fn log_likelihood(&self) -> f64 {
280        let n = self.n as f64;
281        -0.5 * n * ((2.0 * std::f64::consts::PI).ln() + 1.0 + (self.rss / n).ln())
282    }
283
284    /// AIC using the **effective** degrees of freedom as the parameter count:
285    /// `AIC = −2ℓ + 2·df(λ)`.
286    ///
287    /// Using `df(λ)` rather than `p` is what makes information criteria
288    /// meaningful under shrinkage — the model is charged for the degrees of
289    /// freedom it effectively uses, not the nominal column count.
290    pub fn aic(&self) -> f64 {
291        -2.0 * self.log_likelihood() + 2.0 * self.effective_df
292    }
293
294    /// BIC using the effective degrees of freedom: `BIC = −2ℓ + ln(n)·df(λ)`.
295    pub fn bic(&self) -> f64 {
296        -2.0 * self.log_likelihood() + (self.n as f64).ln() * self.effective_df
297    }
298
299    /// Ridge Variance Inflation Factors, aligned to the design columns
300    /// (intercept slot is `NaN`).
301    ///
302    /// Computed as the diagonal of `(R + λ_c I)⁻¹ R (R + λ_c I)⁻¹` on the
303    /// **standardized** predictors, where `R` is their correlation matrix. This
304    /// is the exact variance-inflation of the ridge coefficient estimates and it
305    /// **reduces to the ordinary OLS VIF at `λ = 0`**, which is what makes the
306    /// "VIF before vs after regularization" comparison meaningful: as `λ` grows
307    /// these fall, quantifying how ridge tames collinearity. `λ_c` is the penalty
308    /// on the correlation scale (`λ` divided by `n`, since the correlation matrix
309    /// uses the `1/n`-scaled cross-products).
310    pub fn ridge_vif(&self) -> Vec<f64> {
311        let pred: Vec<usize> = (0..self.p)
312            .filter(|&j| Some(j) != self.intercept_col)
313            .collect();
314        let q = pred.len();
315        let mut out = vec![f64::NAN; self.p];
316        if q == 0 {
317            return out;
318        }
319
320        // Correlation matrix R of the predictors.
321        let n = self.n as f64;
322        let mut means = vec![0.0; q];
323        let mut sds = vec![0.0; q];
324        for (k, &j) in pred.iter().enumerate() {
325            let col = self.x.column(j);
326            let m = col.sum() / n;
327            means[k] = m;
328            sds[k] = (col.iter().map(|v| (v - m).powi(2)).sum::<f64>() / n).sqrt();
329        }
330        let corr = |k: usize, l: usize| -> f64 {
331            if sds[k] <= 0.0 || sds[l] <= 0.0 {
332                return if k == l { 1.0 } else { 0.0 };
333            }
334            let (jk, jl) = (pred[k], pred[l]);
335            let ck = self.x.column(jk);
336            let cl = self.x.column(jl);
337            let cov: f64 = (0..self.n)
338                .map(|i| (ck[i] - means[k]) * (cl[i] - means[l]))
339                .sum::<f64>()
340                / n;
341            cov / (sds[k] * sds[l])
342        };
343
344        let lambda_c = self.lambda / n;
345        // Build (R + λ_c I).
346        let mut a = Array2::<f64>::zeros((q, q));
347        let mut rmat = Array2::<f64>::zeros((q, q));
348        for k in 0..q {
349            for l in 0..q {
350                let r = corr(k, l);
351                rmat[(k, l)] = r;
352                a[(k, l)] = r + if k == l { lambda_c } else { 0.0 };
353            }
354        }
355        // M = A⁻¹ R A⁻¹
356        let a_dm = dmatrix_from_rows(q, q, a.as_standard_layout().as_slice().unwrap());
357        let a_inv = match a_dm.try_inverse() {
358            Some(inv) => inv,
359            None => return out,
360        };
361        let r_dm = dmatrix_from_rows(q, q, rmat.as_standard_layout().as_slice().unwrap());
362        let m = &a_inv * r_dm * &a_inv;
363        for (k, &j) in pred.iter().enumerate() {
364            out[j] = m[(k, k)];
365        }
366        out
367    }
368}
369
370/// Select the ridge penalty that minimizes GCV over a grid of candidate `λ`s,
371/// returning the best [`RidgeFit`].
372///
373/// Refits at each candidate (each fit is a single SVD-based closed-form solve)
374/// and keeps the one with the smallest [`RidgeFit::gcv`]. The grid is the
375/// caller's to choose — a geometric sweep such as `10.^{-3..3}` is typical.
376///
377/// # Errors
378///
379/// [`RegressionError::InvalidParameter`] if `lambdas` is empty; otherwise any
380/// error from [`RidgeFit::new`].
381pub fn select_lambda_gcv(x: Array2<f64>, y: Array1<f64>, lambdas: &[f64]) -> Result<RidgeFit> {
382    if lambdas.is_empty() {
383        return Err(RegressionError::InvalidParameter {
384            msg: "lambda grid must be non-empty".into(),
385        });
386    }
387    let mut best: Option<RidgeFit> = None;
388    for &lam in lambdas {
389        let fit = RidgeFit::new(x.clone(), y.clone(), lam)?;
390        let better = match &best {
391            None => true,
392            Some(b) => fit.gcv() < b.gcv(),
393        };
394        if better {
395            best = Some(fit);
396        }
397    }
398    Ok(best.expect("non-empty grid yields a fit"))
399}
400
401/// Detect the first constant column (treated as the intercept).
402fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
403    for (j, col) in x.columns().into_iter().enumerate() {
404        let first = col[0];
405        let scale = first.abs().max(1.0);
406        if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
407            return Some(j);
408        }
409    }
410    None
411}