linreg_core/regularized/
path.rs

1//! Lambda path generation for regularized regression.
2//!
3//! This module provides utilities for generating a sequence of lambda values
4//! for regularization paths, matching glmnet's approach.
5//!
6//! # Lambda Path Construction
7//!
8//! glmnet generates a lambda path from `lambda_max` down to `lambda_min`:
9//!
10//! - `lambda_max`: The smallest lambda for which all penalized coefficients are zero
11//! - `lambda_min`: `lambda_min_ratio * lambda_max`
12//!
13//! For pure ridge (`alpha=0`), `lambda_max` is theoretically infinite, so we use
14//! a small `alpha` value to compute a finite starting point.
15
16use crate::linalg::Matrix;
17
18/// Options for generating a lambda path.
19///
20/// # Fields
21///
22/// * `nlambda` - Number of lambda values (default: 100)
23/// * `lambda_min_ratio` - Ratio for smallest lambda (default: 0.0001 if n < p, else 0.01)
24/// * `alpha` - Elastic net mixing parameter (0 = ridge, 1 = lasso)
25/// * `eps_for_ridge` - Small alpha to use for computing lambda_max when alpha=0 (default: 0.001)
26#[derive(Clone, Debug)]
27pub struct LambdaPathOptions {
28    /// Number of lambda values to generate
29    pub nlambda: usize,
30    /// Minimum lambda as a fraction of maximum
31    pub lambda_min_ratio: Option<f64>,
32    /// Elastic net mixing parameter (0 = ridge, 1 = lasso)
33    pub alpha: f64,
34    /// Small alpha to use for ridge lambda_max computation
35    pub eps_for_ridge: f64,
36}
37
38impl Default for LambdaPathOptions {
39    fn default() -> Self {
40        LambdaPathOptions {
41            nlambda: 100,
42            lambda_min_ratio: None,
43            alpha: 1.0,
44            eps_for_ridge: 0.001,
45        }
46    }
47}
48
49/// Computes `lambda_max`: the smallest lambda for which all penalized coefficients are zero.
50///
51/// For lasso (alpha > 0), lambda_max is the smallest value such that the soft-thresholding
52/// operation zeros out all coefficients.
53///
54/// For standardized X and centered y:
55/// ```text
56/// lambda_max = max_j |x_j^T y| / (n * alpha)
57/// ```
58///
59/// # Arguments
60///
61/// * `x` - Standardized design matrix (n × p), first column is intercept if present
62/// * `y` - Centered response vector (n elements)
63/// * `alpha` - Elastic net mixing parameter
64/// * `penalty_factor` - Per-feature penalty factors (optional, defaults to all 1.0)
65/// * `intercept_col` - Index of intercept column (typically 0, or None if no intercept)
66///
67/// # Returns
68///
69/// The maximum lambda value for the path.
70///
71/// # Note
72///
73/// If `alpha = 0` (pure ridge), this returns `f64::INFINITY` since ridge never produces
74/// exact zero coefficients. Use [`make_lambda_path`] which handles this case by using
75/// a small alpha value.
76pub fn compute_lambda_max(
77    x: &Matrix,
78    y: &[f64],
79    alpha: f64,
80    penalty_factor: Option<&[f64]>,
81    intercept_col: Option<usize>,
82) -> f64 {
83    if alpha <= 0.0 {
84        return f64::INFINITY;
85    }
86
87    let n = x.rows as f64;
88    let p = x.cols;
89
90    let mut max_corr: f64 = 0.0;
91
92    for j in 0..p {
93        // Skip intercept column
94        if let Some(ic) = intercept_col {
95            if j == ic {
96                continue;
97            }
98        }
99
100        // Compute absolute correlation: |x_j^T y|
101        // Matrix is row-major, so we iterate through rows for each column
102        let mut corr = 0.0;
103        for i in 0..x.rows {
104            corr += x.get(i, j) * y[i];
105        }
106        let corr = corr.abs();
107
108        // Apply penalty factor if provided
109        let effective_corr = if let Some(pf) = penalty_factor {
110            if j < pf.len() && pf[j] > 0.0 {
111                corr / pf[j]
112            } else {
113                corr
114            }
115        } else {
116            corr
117        };
118
119        max_corr = max_corr.max(effective_corr);
120    }
121
122    max_corr / (n * alpha)
123}
124
125/// Generates a lambda path from lambda_max down to lambda_min.
126///
127/// This creates a logarithmically-spaced sequence of lambda values, matching
128/// glmnet's approach for regularization paths.
129///
130/// # Arguments
131///
132/// * `x` - Standardized design matrix (n × p)
133/// * `y` - Centered response vector (n elements)
134/// * `options` - Lambda path generation options
135/// * `penalty_factor` - Optional per-feature penalty factors
136/// * `intercept_col` - Index of intercept column (typically 0)
137///
138/// # Returns
139///
140/// A vector of lambda values in **decreasing** order (largest to smallest).
141///
142/// # Lambda Sequence
143///
144/// The lambda values are logarithmically spaced:
145/// ```text
146/// lambda[k] = lambda_max * exp(log(lambda_min_ratio) * k / (nlambda - 1))
147/// ```
148///
149/// For ridge (`alpha ≈ 0`), we use a small alpha value to compute a finite
150/// `lambda_max`, then use that lambda sequence for the actual ridge fit.
151///
152/// # Default lambda_min_ratio
153///
154/// Following glmnet:
155/// - If `n >= p`: `lambda_min_ratio = 0.0001`
156/// - If `n < p`: `lambda_min_ratio = 0.01`
157pub fn make_lambda_path(
158    x: &Matrix,
159    y: &[f64],
160    options: &LambdaPathOptions,
161    penalty_factor: Option<&[f64]>,
162    intercept_col: Option<usize>,
163) -> Vec<f64> {
164    let n = x.rows;
165    let p = x.cols;
166
167    // Determine default lambda_min_ratio
168    let default_min_ratio = if n >= p { 0.0001 } else { 0.01 };
169    let lambda_min_ratio = options.lambda_min_ratio.unwrap_or(default_min_ratio);
170
171    // For pure ridge (alpha = 0), use a small alpha to compute lambda_max
172    let alpha_for_lambda_max = if options.alpha <= 0.0 {
173        options.eps_for_ridge
174    } else {
175        options.alpha
176    };
177
178    // Compute lambda_max
179    let lambda_max = compute_lambda_max(
180        x,
181        y,
182        alpha_for_lambda_max,
183        penalty_factor,
184        intercept_col,
185    );
186
187    // Handle the case where lambda_max is infinite or very small
188    if !lambda_max.is_finite() || lambda_max <= 0.0 {
189        // For ridge with no good lambda_max, return a default path
190        return (0..options.nlambda)
191            .map(|k| {
192                let t = k as f64 / (options.nlambda - 1) as f64;
193                10.0_f64.powf(2.0 * (1.0 - t))  // 10^2 down to 10^0
194            })
195            .collect();
196    }
197
198    let _lambda_min = lambda_min_ratio * lambda_max;
199
200    // Generate logarithmically spaced lambdas (decreasing)
201    (0..options.nlambda)
202        .map(|k| {
203            let t = k as f64 / (options.nlambda - 1) as f64;
204            lambda_max * (lambda_min_ratio.powf(t))
205        })
206        .collect()
207}
208
209/// Extracts a specific set of lambdas from a path.
210///
211/// This is useful when you want to evaluate at specific lambda values
212/// rather than using the full path.
213///
214/// # Arguments
215///
216/// * `full_path` - The complete lambda path (must be in decreasing order)
217/// * `indices` - Indices of lambdas to extract
218///
219/// # Returns
220///
221/// A new vector containing the specified lambda values.
222pub fn extract_lambdas(full_path: &[f64], indices: &[usize]) -> Vec<f64> {
223    indices.iter().map(|&i| full_path[i]).collect()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_compute_lambda_max() {
232        // Simple test: X = [1, x], y = [1, 2, 3]
233        let x_data = vec![
234            1.0, -1.0,
235            1.0, 0.0,
236            1.0, 1.0,
237        ];
238        let x = Matrix::new(3, 2, x_data);
239        let y = vec![1.0, 2.0, 3.0];
240
241        // Center y
242        let y_mean: f64 = y.iter().sum::<f64>() / 3.0;
243        let y_centered: Vec<f64> = y.iter().map(|&yi| yi - y_mean).collect();
244
245        let lambda_max = compute_lambda_max(&x, &y_centered, 1.0, None, Some(0));
246
247        // x^T y for column 1: -1*0 + 0*0 + 1*0 = 0 (since y is centered)
248        // Actually y_centered = [-1, 0, 1], so x^T y = 1*(-1) + 0*0 + 1*1 = 0
249        // y = [1,2,3], y_mean = 2, y_centered = [-1, 0, 1]
250        // back checks...
251        // Column 1 of X: [-1, 0, 1]
252        // dot = (-1)*(-1) + 0*0 + 1*1 = 1 + 0 + 1 = 2
253        // lambda_max = 2 / (3 * 1) = 2/3
254        assert!((lambda_max - 2.0 / 3.0).abs() < 1e-10);
255    }
256
257    #[test]
258    fn test_make_lambda_path_decreasing() {
259        let x_data = vec![
260            1.0, -1.0,
261            1.0, 0.0,
262            1.0, 1.0,
263        ];
264        let x = Matrix::new(3, 2, x_data);
265        let y_centered = vec![-1.0, 0.0, 1.0];
266
267        let options = LambdaPathOptions {
268            nlambda: 10,
269            lambda_min_ratio: Some(0.01),
270            alpha: 1.0,
271            eps_for_ridge: 0.001,
272        };
273
274        let path = make_lambda_path(&x, &y_centered, &options, None, Some(0));
275
276        assert_eq!(path.len(), 10);
277
278        // Check that path is decreasing
279        for i in 1..path.len() {
280            assert!(path[i] < path[i - 1]);
281        }
282
283        // First value should be lambda_max, last should be lambda_max * 0.01
284        let lambda_max = 2.0 / 3.0;
285        assert!((path[0] - lambda_max).abs() < 1e-10);
286        assert!((path[9] - lambda_max * 0.01).abs() < 1e-10);
287    }
288
289    #[test]
290    fn test_lambda_max_ridge() {
291        let x_data = vec![
292            1.0, -1.0,
293            1.0, 0.0,
294            1.0, 1.0,
295        ];
296        let x = Matrix::new(3, 2, x_data);
297        let y = vec![-1.0, 0.0, 1.0];
298
299        // For alpha = 0 (ridge), lambda_max should be infinite
300        let lambda_max = compute_lambda_max(&x, &y, 0.0, None, Some(0));
301        assert!(lambda_max.is_infinite());
302    }
303
304    #[test]
305    fn test_extract_lambdas() {
306        let path = vec![10.0, 5.0, 2.5, 1.25, 0.625];
307        let indices = vec![0, 2, 4];
308        let extracted = extract_lambdas(&path, &indices);
309
310        assert_eq!(extracted, vec![10.0, 2.5, 0.625]);
311    }
312}