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}