Skip to main content

linreg_core/regularized/
ridge.rs

1//! Ridge regression (L2-regularized linear regression).
2//!
3//! This module provides a wrapper around the elastic net implementation with `alpha=0.0`.
4
5use crate::error::Result;
6use crate::linalg::Matrix;
7use crate::regularized::elastic_net::{elastic_net_fit, ElasticNetOptions};
8use crate::regularized::preprocess::predict;
9
10#[cfg(feature = "wasm")]
11use serde::Serialize;
12
13/// Options for ridge regression fitting.
14///
15/// Configuration options for ridge regression (L2-regularized linear regression).
16///
17/// # Fields
18///
19/// - `lambda` - Regularization strength (≥ 0, higher = more shrinkage)
20/// - `intercept` - Whether to include an intercept term
21/// - `standardize` - Whether to standardize predictors to unit variance
22/// - `max_iter` - Maximum coordinate descent iterations
23/// - `tol` - Convergence tolerance on coefficient changes
24/// - `warm_start` - Optional initial coefficient values for warm starts
25/// - `weights` - Optional observation weights
26///
27/// # Example
28///
29/// ```
30/// # use linreg_core::regularized::ridge::RidgeFitOptions;
31/// let options = RidgeFitOptions {
32///     lambda: 1.0,
33///     intercept: true,
34///     standardize: true,
35///     ..Default::default()
36/// };
37/// ```
38#[derive(Clone, Debug)]
39pub struct RidgeFitOptions {
40    pub lambda: f64,
41    pub intercept: bool,
42    pub standardize: bool,
43    pub max_iter: usize, // Added for consistency
44    pub tol: f64,        // Added for consistency
45    pub warm_start: Option<Vec<f64>>,
46    pub weights: Option<Vec<f64>>, // Observation weights
47}
48
49impl Default for RidgeFitOptions {
50    fn default() -> Self {
51        RidgeFitOptions {
52            lambda: 1.0,
53            intercept: true,
54            standardize: true,
55            max_iter: 100000,
56            tol: 1e-7,
57            warm_start: None,
58            weights: None,
59        }
60    }
61}
62
63/// Result of a ridge regression fit.
64///
65/// Contains the fitted model coefficients, predictions, and diagnostic metrics.
66///
67/// # Fields
68///
69/// - `lambda` - The regularization strength used
70/// - `intercept` - Intercept coefficient (never penalized)
71/// - `coefficients` - Slope coefficients (penalized)
72/// - `fitted_values` - Predicted values on training data
73/// - `residuals` - Residuals (y - fitted_values)
74/// - `df` - Approximate effective degrees of freedom
75/// - `r_squared` - Coefficient of determination
76/// - `adj_r_squared` - Adjusted R²
77/// - `mse` - Mean squared error
78/// - `rmse` - Root mean squared error
79/// - `mae` - Mean absolute error
80///
81/// # Example
82///
83/// ```
84/// # use linreg_core::regularized::ridge::{ridge_fit, RidgeFitOptions};
85/// # use linreg_core::linalg::Matrix;
86/// # let y = vec![2.0, 4.0, 6.0, 8.0];
87/// # let x = Matrix::new(4, 2, vec![1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0]);
88/// # let options = RidgeFitOptions { lambda: 0.1, intercept: true, standardize: false, ..Default::default() };
89/// let fit = ridge_fit(&x, &y, &options).unwrap();
90///
91/// // Access model coefficients
92/// println!("Intercept: {}", fit.intercept);
93/// println!("Slopes: {:?}", fit.coefficients);
94///
95/// // Access predictions and diagnostics
96/// println!("R²: {}", fit.r_squared);
97/// println!("RMSE: {}", fit.rmse);
98/// # Ok::<(), linreg_core::Error>(())
99/// ```
100#[derive(Clone, Debug)]
101#[cfg_attr(feature = "wasm", derive(Serialize))]
102pub struct RidgeFit {
103    pub lambda: f64,
104    pub intercept: f64,
105    pub coefficients: Vec<f64>,
106    pub fitted_values: Vec<f64>,
107    pub residuals: Vec<f64>,
108    pub df: f64, // Still computed, though approximation
109    pub r_squared: f64,
110    pub adj_r_squared: f64,
111    pub mse: f64,
112    pub rmse: f64,
113    pub mae: f64,
114}
115
116/// Fits ridge regression for a single lambda value.
117///
118/// Ridge regression adds an L2 penalty to the coefficients, which helps with
119/// multicollinearity and overfitting. The intercept is never penalized.
120///
121/// # Arguments
122///
123/// * `x` - Design matrix (n rows × p columns including intercept)
124/// * `y` - Response variable (n observations)
125/// * `options` - Configuration options for ridge regression
126///
127/// # Returns
128///
129/// A `RidgeFit` containing coefficients, fitted values, residuals, and metrics.
130///
131/// # Example
132///
133/// ```
134/// # use linreg_core::regularized::ridge::{ridge_fit, RidgeFitOptions};
135/// # use linreg_core::linalg::Matrix;
136/// let y = vec![2.0, 4.0, 6.0, 8.0];
137/// let x = Matrix::new(4, 2, vec![1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0]);
138///
139/// let options = RidgeFitOptions {
140///     lambda: 0.1,
141///     intercept: true,
142///     standardize: false,
143///     ..Default::default()
144/// };
145///
146/// let fit = ridge_fit(&x, &y, &options).unwrap();
147/// assert!(fit.coefficients.len() == 1); // One slope coefficient
148/// assert!(fit.r_squared > 0.9); // Good fit for linear data
149/// # Ok::<(), linreg_core::Error>(())
150/// ```
151pub fn ridge_fit(x: &Matrix, y: &[f64], options: &RidgeFitOptions) -> Result<RidgeFit> {
152    // DEBUG: Print lambda info
153    // #[cfg(debug_assertions)]
154    // {
155    //     eprintln!("DEBUG ridge_fit: user_lambda = {}, standardize = {}", options.lambda, options.standardize);
156    // }
157
158    let en_options = ElasticNetOptions {
159        lambda: options.lambda,
160        alpha: 0.0, // Ridge
161        intercept: options.intercept,
162        standardize: options.standardize,
163        max_iter: options.max_iter,
164        tol: options.tol,
165        penalty_factor: None,
166        warm_start: options.warm_start.clone(),
167        weights: options.weights.clone(),
168        coefficient_bounds: None,
169    };
170
171    let fit = elastic_net_fit(x, y, &en_options)?;
172
173    // #[cfg(debug_assertions)]
174    // {
175    //     eprintln!("DEBUG ridge_fit: fit.intercept = {}, fit.coefficients[0] = {}", fit.intercept,
176    //              fit.coefficients.first().unwrap_or(&0.0));
177    // }
178
179    // Approximation of degrees of freedom for ridge regression.
180    //
181    // The true effective df requires SVD: sum(eigenvalues / (eigenvalues + lambda)).
182    // Since coordinate descent doesn't compute the SVD, we use a closed-form approximation
183    // that works well when X is standardized: df ≈ p / (1 + lambda).
184    //
185    // This approximation is reasonable for most practical purposes. For exact df,
186    // users would need to implement SVD-based calculation separately.
187    let p = x.cols;
188    let df = (p as f64) / (1.0 + options.lambda);
189
190    Ok(RidgeFit {
191        lambda: fit.lambda,
192        intercept: fit.intercept,
193        coefficients: fit.coefficients,
194        fitted_values: fit.fitted_values,
195        residuals: fit.residuals,
196        df,
197        r_squared: fit.r_squared,
198        adj_r_squared: fit.adj_r_squared,
199        mse: fit.mse,
200        rmse: fit.rmse,
201        mae: fit.mae,
202    })
203}
204
205/// Makes predictions using a ridge regression fit.
206///
207/// Computes predictions for new observations using the fitted ridge regression model.
208///
209/// # Arguments
210///
211/// * `fit` - Fitted ridge regression model
212/// * `x_new` - New design matrix (same number of columns as training data)
213///
214/// # Returns
215///
216/// Vector of predicted values.
217///
218/// # Example
219///
220/// ```
221/// # use linreg_core::regularized::ridge::{ridge_fit, predict_ridge, RidgeFitOptions};
222/// # use linreg_core::linalg::Matrix;
223/// // Training data
224/// let y = vec![2.0, 4.0, 6.0, 8.0];
225/// let x = Matrix::new(4, 2, vec![1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0]);
226///
227/// let options = RidgeFitOptions {
228///     lambda: 0.1,
229///     intercept: true,
230///     standardize: false,
231///     ..Default::default()
232/// };
233/// let fit = ridge_fit(&x, &y, &options).unwrap();
234///
235/// // Predict on new data
236/// let x_new = Matrix::new(2, 2, vec![1.0, 5.0, 1.0, 6.0]);
237/// let predictions = predict_ridge(&fit, &x_new);
238///
239/// assert_eq!(predictions.len(), 2);
240/// // Predictions should be close to [10.0, 12.0] for the linear relationship y = 2*x
241/// # Ok::<(), linreg_core::Error>(())
242/// ```
243pub fn predict_ridge(fit: &RidgeFit, x_new: &Matrix) -> Vec<f64> {
244    predict(x_new, fit.intercept, &fit.coefficients)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_ridge_fit_simple() {
253        let x_data = vec![1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0];
254        let x = Matrix::new(4, 2, x_data);
255        let y = vec![2.0, 4.0, 6.0, 8.0];
256
257        let options = RidgeFitOptions {
258            lambda: 0.1,
259            intercept: true,
260            standardize: false,
261            ..Default::default()
262        };
263
264        let fit = ridge_fit(&x, &y, &options).unwrap();
265
266        // OLS: intercept ≈ 0, slope ≈ 2
267        assert!((fit.coefficients[0] - 2.0).abs() < 0.2);
268        assert!(fit.intercept.abs() < 0.5);
269    }
270}