Skip to main content

oxicuda_cs/lasso/
lars.rs

1//! Least Angle Regression / LASSO via LARS (Efron-Hastie-Johnstone-Tibshirani 2004).
2//!
3//! Produces the entire piecewise-linear LASSO path. At each iteration:
4//! 1. Compute correlations `c = Φᵀ r`.
5//! 2. Identify max |c| among inactive variables; add to active set with the sign of its correlation.
6//! 3. Compute equiangular direction `u` such that `Φ_A^T u = sign(c_A) * 1`.
7//! 4. Step along the path until either: (a) a new variable joins the active set, or (b) an
8//!    active variable's coefficient hits zero (LASSO modification — Lemma 5 of Efron et al.).
9
10use crate::error::{CsError, CsResult};
11use crate::linalg::cholesky::{cholesky_factor, cholesky_solve};
12use crate::linalg::{mat_t_vec, mat_vec, submat_columns};
13
14/// One step of the LARS path: betas at that breakpoint along with the lambda value.
15#[derive(Debug, Clone)]
16pub struct LarsStep {
17    pub lambda: f64,
18    pub beta: Vec<f64>,
19    pub active: Vec<usize>,
20}
21
22/// Output of the LARS solver: ordered list of breakpoints from λ=λ_max down to λ_min (≥ 0).
23#[derive(Debug, Clone)]
24pub struct LarsPath {
25    pub steps: Vec<LarsStep>,
26}
27
28/// Run LARS / LASSO and return the piecewise path of solutions.
29///
30/// Uses the LASSO modification (Efron et al. §3.6) — when an active coefficient changes sign
31/// during a step we truncate the step and drop that variable. Bounded by `max_iter` total events.
32pub fn lars(phi: &[f64], m: usize, n: usize, y: &[f64], max_iter: usize) -> CsResult<LarsPath> {
33    if phi.len() != m * n {
34        return Err(CsError::ShapeMismatch {
35            expected: vec![m, n],
36            got: vec![phi.len()],
37        });
38    }
39    if y.len() != m {
40        return Err(CsError::DimensionMismatch { a: y.len(), b: m });
41    }
42    let mut active: Vec<usize> = Vec::new();
43    let mut signs: Vec<f64> = Vec::new();
44    let mut beta = vec![0.0_f64; n];
45    let mut residual = y.to_vec();
46    let mut steps: Vec<LarsStep> = Vec::new();
47    let max_steps = max_iter.min(2 * n.min(m));
48    for _step in 0..max_steps {
49        let corr = mat_t_vec(phi, m, n, &residual)?;
50        // Find max absolute correlation.
51        let mut c_max = 0.0_f64;
52        for (j, &c) in corr.iter().enumerate() {
53            let a = c.abs();
54            if !active.contains(&j) && a > c_max {
55                c_max = a;
56            }
57        }
58        // Save current point.
59        steps.push(LarsStep {
60            lambda: c_max,
61            beta: beta.clone(),
62            active: active.clone(),
63        });
64        if c_max < 1.0e-12 {
65            break;
66        }
67        // Add the variable with max correlation.
68        let mut new_idx = usize::MAX;
69        let mut best_val = 0.0_f64;
70        for (j, &c) in corr.iter().enumerate() {
71            if active.contains(&j) {
72                continue;
73            }
74            let a = c.abs();
75            if a > best_val {
76                best_val = a;
77                new_idx = j;
78            }
79        }
80        if new_idx == usize::MAX {
81            break;
82        }
83        let sign_new = corr[new_idx].signum();
84        active.push(new_idx);
85        signs.push(sign_new);
86        // Build active matrix Φ_A.
87        let phi_a = submat_columns(phi, m, n, &active)?;
88        let a_sz = active.len();
89        // Solve (Φ_Aᵀ Φ_A) w = signs => w is unscaled equiangular direction.
90        let mut g = vec![0.0_f64; a_sz * a_sz];
91        for i in 0..m {
92            for a in 0..a_sz {
93                let pai = phi_a[i * a_sz + a];
94                for b in 0..a_sz {
95                    g[a * a_sz + b] += pai * phi_a[i * a_sz + b];
96                }
97            }
98        }
99        // Tikhonov for numerical stability.
100        for d in 0..a_sz {
101            g[d * a_sz + d] += 1.0e-12;
102        }
103        let l = cholesky_factor(&g, a_sz)?;
104        let w = cholesky_solve(&l, a_sz, &signs)?;
105        // Scaling so that ||Φ_A w|| · |signs|.
106        let dot_wsigns: f64 = w.iter().zip(signs.iter()).map(|(a, b)| a * b).sum();
107        let aa = if dot_wsigns > 0.0 {
108            1.0 / dot_wsigns.sqrt()
109        } else {
110            return Err(CsError::NumericalInstability(
111                "LARS: negative or zero dot(w, signs)".into(),
112            ));
113        };
114        let u_a: Vec<f64> = w.iter().map(|wi| wi * aa).collect();
115        // Direction in original space: d_j = signs_a * aa applied to active columns.
116        let mut d_beta = vec![0.0_f64; n];
117        for (k, &j) in active.iter().enumerate() {
118            d_beta[j] = u_a[k];
119        }
120        // Equiangular vector eq = Φ d_beta.
121        let eq = mat_vec(phi, m, n, &d_beta)?;
122        // Correlations of eq with each j: a_j = Φ_j^T eq.
123        let a_j = mat_t_vec(phi, m, n, &eq)?;
124        // Compute step length γ.
125        let mut gamma = c_max / aa;
126        if active.len() < n {
127            for (j, &cj) in corr.iter().enumerate() {
128                if active.contains(&j) {
129                    continue;
130                }
131                let aj = a_j[j];
132                let g1 = (c_max - cj) / (aa - aj);
133                let g2 = (c_max + cj) / (aa + aj);
134                if g1 > 1.0e-12 && g1 < gamma {
135                    gamma = g1;
136                }
137                if g2 > 1.0e-12 && g2 < gamma {
138                    gamma = g2;
139                }
140            }
141        }
142        // LASSO modification: detect sign changes within active variables.
143        let mut hit_zero = usize::MAX;
144        let mut gamma_zero = gamma;
145        for (k, &j) in active.iter().enumerate() {
146            let dj = d_beta[j];
147            if dj.abs() > 1.0e-12 {
148                let t = -beta[j] / dj;
149                if t > 1.0e-12 && t < gamma_zero {
150                    gamma_zero = t;
151                    hit_zero = k;
152                }
153            }
154        }
155        let actual_gamma = if hit_zero != usize::MAX {
156            gamma_zero
157        } else {
158            gamma
159        };
160        // Take the step.
161        for j in 0..n {
162            beta[j] += actual_gamma * d_beta[j];
163        }
164        // Update residual.
165        for i in 0..m {
166            residual[i] -= actual_gamma * eq[i];
167        }
168        // Drop variable hitting zero (LASSO mod).
169        if hit_zero != usize::MAX {
170            let idx_drop = active[hit_zero];
171            beta[idx_drop] = 0.0;
172            active.remove(hit_zero);
173            signs.remove(hit_zero);
174        }
175    }
176    steps.push(LarsStep {
177        lambda: 0.0,
178        beta: beta.clone(),
179        active: active.clone(),
180    });
181    Ok(LarsPath { steps })
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn lars_runs() {
190        // Use a slightly perturbed Φ to avoid degenerate tie patterns.
191        let phi = vec![1.0, 0.1, 0.0, 1.0];
192        let y = vec![1.0, 1.0];
193        let path = lars(&phi, 2, 2, &y, 10).expect("ok");
194        assert!(!path.steps.is_empty());
195        // The path should include a final step at lambda ≈ 0 with beta close to OLS solution.
196        let near_zero = path
197            .steps
198            .iter()
199            .min_by(|a, b| {
200                a.lambda
201                    .abs()
202                    .partial_cmp(&b.lambda.abs())
203                    .unwrap_or(std::cmp::Ordering::Equal)
204            })
205            .expect("ok");
206        // OLS: solve A x = b directly: [[1, 0.1], [0, 1]] x = [1, 1] -> x = [0.9, 1].
207        assert!(
208            (near_zero.beta[0] - 0.9).abs() < 0.2,
209            "beta[0]={}",
210            near_zero.beta[0]
211        );
212        assert!(
213            (near_zero.beta[1] - 1.0).abs() < 0.2,
214            "beta[1]={}",
215            near_zero.beta[1]
216        );
217    }
218}