Skip to main content

regression_diagnostics/regularized/
lasso.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3use crate::error::{RegressionError, Result};
4
5/// A fitted lasso-regression model and its diagnostics.
6///
7/// Lasso solves `min (1/2n)‖y − Xβ‖² + λ‖β_pen‖₁`. Unlike ridge there is no
8/// closed form, so this fits by **cyclic coordinate descent** with
9/// soft-thresholding — the standard, well-conditioned algorithm (glmnet-style).
10///
11/// # Intercept and scaling
12///
13/// A detected constant column is an **unpenalized intercept**. Predictors are
14/// **standardized internally** (centered and scaled to unit variance) before the
15/// penalty is applied, then coefficients are transformed back to the original
16/// scale; the intercept is recovered from the means. Because standardization is
17/// internal, `λ` is on the standardized `(1/2n)`-objective scale — not comparable
18/// to ridge's `λ`.
19///
20/// # The natural diagnostic: the active set
21///
22/// Lasso's defining behavior is that it drives coefficients *exactly* to zero.
23/// The size of the surviving [`active_set`](Self::active_set) is an unbiased
24/// estimate of the model's degrees of freedom (Zou, Hastie & Tibshirani, 2007),
25/// which is what the information criteria here use as the parameter count.
26#[derive(Debug, Clone)]
27pub struct LassoFit {
28    x: Array2<f64>,
29    y: Array1<f64>,
30    lambda: f64,
31    coefficients: Array1<f64>,
32    fitted: Array1<f64>,
33    residuals: Array1<f64>,
34    rss: f64,
35    intercept_col: Option<usize>,
36    n_nonzero: usize,
37    iterations: usize,
38    n: usize,
39    p: usize,
40}
41
42impl LassoFit {
43    /// Fit lasso regression of `y` on `X` with penalty `lambda ≥ 0` using
44    /// coordinate descent (default tolerance `1e-7`, up to `10_000` sweeps).
45    ///
46    /// # Errors
47    ///
48    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
49    /// * [`RegressionError::InvalidParameter`] if `lambda < 0`.
50    /// * [`RegressionError::NotConverged`] if coordinate descent does not
51    ///   converge within the iteration budget.
52    pub fn new(x: Array2<f64>, y: Array1<f64>, lambda: f64) -> Result<Self> {
53        Self::with_options(x, y, lambda, 1e-7, 10_000)
54    }
55
56    /// Like [`LassoFit::new`] but with an explicit convergence tolerance and
57    /// maximum number of coordinate-descent sweeps.
58    pub fn with_options(
59        x: Array2<f64>,
60        y: Array1<f64>,
61        lambda: f64,
62        tol: f64,
63        max_iter: usize,
64    ) -> Result<Self> {
65        if x.nrows() == 0 || x.ncols() == 0 {
66            return Err(RegressionError::EmptyInput { what: "X" });
67        }
68        if y.len() != x.nrows() {
69            return Err(RegressionError::ShapeMismatch {
70                what: "y length vs X rows",
71                expected: x.nrows(),
72                got: y.len(),
73            });
74        }
75        if lambda < 0.0 || lambda.is_nan() {
76            return Err(RegressionError::InvalidParameter {
77                msg: format!("lasso lambda must be >= 0, got {lambda}"),
78            });
79        }
80
81        let n = x.nrows();
82        let p = x.ncols();
83        let intercept_col = detect_constant_column(&x);
84        let has_intercept = intercept_col.is_some();
85        let pred: Vec<usize> = (0..p).filter(|&j| Some(j) != intercept_col).collect();
86        let q = pred.len();
87
88        let nf = n as f64;
89        let y_mean = if has_intercept { y.sum() / nf } else { 0.0 };
90
91        // Standardize predictors: z_j = (x_j − mean_j) / sd_j, with population sd.
92        let mut means = vec![0.0; q];
93        let mut sds = vec![1.0; q];
94        for (k, &j) in pred.iter().enumerate() {
95            let col = x.column(j);
96            let m = if has_intercept { col.sum() / nf } else { 0.0 };
97            means[k] = m;
98            let sd = (col.iter().map(|v| (v - m).powi(2)).sum::<f64>() / nf).sqrt();
99            sds[k] = if sd > 0.0 { sd } else { 1.0 };
100        }
101        // z: n × q standardized design.
102        let mut z = vec![0.0f64; n * q];
103        for i in 0..n {
104            for (k, &j) in pred.iter().enumerate() {
105                z[i * q + k] = (x[(i, j)] - means[k]) / sds[k];
106            }
107        }
108        let yc: Vec<f64> = (0..n).map(|i| y[i] - y_mean).collect();
109
110        // Coordinate descent on standardized coefficients β (length q).
111        let mut beta = vec![0.0f64; q];
112        // residual r = yc − Z β (starts at yc since β = 0).
113        let mut r = yc.clone();
114        let mut iterations = 0usize;
115        let mut converged = false;
116        while iterations < max_iter {
117            iterations += 1;
118            let mut max_delta = 0.0f64;
119            for k in 0..q {
120                // ρ_k = (1/n) z_kᵀ r + β_k   (since (1/n) z_kᵀ z_k = 1)
121                let mut zr = 0.0;
122                for i in 0..n {
123                    zr += z[i * q + k] * r[i];
124                }
125                let rho = zr / nf + beta[k];
126                let new = soft_threshold(rho, lambda);
127                let delta = new - beta[k];
128                if delta != 0.0 {
129                    // Update residual: r -= z_k * delta
130                    for i in 0..n {
131                        r[i] -= z[i * q + k] * delta;
132                    }
133                    beta[k] = new;
134                    max_delta = max_delta.max(delta.abs());
135                }
136            }
137            if max_delta < tol {
138                converged = true;
139                break;
140            }
141        }
142        if !converged {
143            return Err(RegressionError::NotConverged {
144                iterations,
145                msg: "lasso coordinate descent did not reach tolerance".into(),
146            });
147        }
148
149        // Transform back to original scale: β_orig_j = β_std / sd_j.
150        let mut coefficients = Array1::<f64>::zeros(p);
151        let mut slopes_orig = vec![0.0; q];
152        for (k, &j) in pred.iter().enumerate() {
153            let b = beta[k] / sds[k];
154            slopes_orig[k] = b;
155            coefficients[j] = b;
156        }
157        if let Some(c) = intercept_col {
158            coefficients[c] = y_mean - (0..q).map(|k| means[k] * slopes_orig[k]).sum::<f64>();
159        }
160
161        let fitted = x.dot(&coefficients);
162        let residuals = &y - &fitted;
163        let rss: f64 = residuals.iter().map(|e| e * e).sum();
164        let n_nonzero = slopes_orig.iter().filter(|b| b.abs() > 1e-12).count();
165
166        Ok(Self {
167            x,
168            y,
169            lambda,
170            coefficients,
171            fitted,
172            residuals,
173            rss,
174            intercept_col,
175            n_nonzero,
176            iterations,
177            n,
178            p,
179        })
180    }
181
182    /// The penalty `λ` this model was fit with.
183    pub fn lambda(&self) -> f64 {
184        self.lambda
185    }
186
187    /// Number of observations.
188    pub fn n_observations(&self) -> usize {
189        self.n
190    }
191
192    /// Number of coefficients (design columns, intercept included).
193    pub fn n_parameters(&self) -> usize {
194        self.p
195    }
196
197    /// Whether an unpenalized intercept is present.
198    pub fn has_intercept(&self) -> bool {
199        self.intercept_col.is_some()
200    }
201
202    /// Coordinate-descent sweeps taken to converge.
203    pub fn iterations(&self) -> usize {
204        self.iterations
205    }
206
207    /// The design matrix as fitted.
208    pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
209        self.x.view()
210    }
211
212    /// Lasso coefficients, aligned to the design columns. Penalized coefficients
213    /// that were shrunk out are exactly `0.0`.
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    /// Response vector.
234    pub fn response(&self) -> ArrayView1<'_, f64> {
235        self.y.view()
236    }
237
238    /// Indices of the design columns with non-zero (surviving) coefficients —
239    /// the **active set**. Excludes the intercept.
240    pub fn active_set(&self) -> Vec<usize> {
241        (0..self.p)
242            .filter(|&j| Some(j) != self.intercept_col && self.coefficients[j].abs() > 1e-12)
243            .collect()
244    }
245
246    /// Number of non-zero penalized coefficients — the active-set size, which is
247    /// lasso's degrees-of-freedom estimate (Zou–Hastie–Tibshirani).
248    pub fn n_nonzero(&self) -> usize {
249        self.n_nonzero
250    }
251
252    /// Effective degrees of freedom: the active-set size plus one for the
253    /// intercept if present.
254    pub fn effective_df(&self) -> f64 {
255        self.n_nonzero as f64 + if self.has_intercept() { 1.0 } else { 0.0 }
256    }
257
258    /// Gaussian log-likelihood at the fitted residual variance.
259    pub fn log_likelihood(&self) -> f64 {
260        let n = self.n as f64;
261        -0.5 * n * ((2.0 * std::f64::consts::PI).ln() + 1.0 + (self.rss / n).ln())
262    }
263
264    /// AIC using the active-set-based degrees of freedom as the parameter count.
265    pub fn aic(&self) -> f64 {
266        -2.0 * self.log_likelihood() + 2.0 * self.effective_df()
267    }
268
269    /// BIC using the active-set-based degrees of freedom.
270    pub fn bic(&self) -> f64 {
271        -2.0 * self.log_likelihood() + (self.n as f64).ln() * self.effective_df()
272    }
273}
274
275/// Soft-thresholding operator `sign(a)·max(|a| − λ, 0)`.
276fn soft_threshold(a: f64, lambda: f64) -> f64 {
277    if a > lambda {
278        a - lambda
279    } else if a < -lambda {
280        a + lambda
281    } else {
282        0.0
283    }
284}
285
286/// Detect the first constant column (treated as the intercept).
287fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
288    for (j, col) in x.columns().into_iter().enumerate() {
289        let first = col[0];
290        let scale = first.abs().max(1.0);
291        if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
292            return Some(j);
293        }
294    }
295    None
296}