regression_diagnostics/regularized/
lasso.rs1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3use crate::error::{RegressionError, Result};
4
5#[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 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 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 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 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 let mut beta = vec![0.0f64; q];
112 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 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 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 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 pub fn lambda(&self) -> f64 {
184 self.lambda
185 }
186
187 pub fn n_observations(&self) -> usize {
189 self.n
190 }
191
192 pub fn n_parameters(&self) -> usize {
194 self.p
195 }
196
197 pub fn has_intercept(&self) -> bool {
199 self.intercept_col.is_some()
200 }
201
202 pub fn iterations(&self) -> usize {
204 self.iterations
205 }
206
207 pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
209 self.x.view()
210 }
211
212 pub fn coefficients(&self) -> ArrayView1<'_, f64> {
215 self.coefficients.view()
216 }
217
218 pub fn fitted_values(&self) -> ArrayView1<'_, f64> {
220 self.fitted.view()
221 }
222
223 pub fn residuals(&self) -> ArrayView1<'_, f64> {
225 self.residuals.view()
226 }
227
228 pub fn residual_sum_of_squares(&self) -> f64 {
230 self.rss
231 }
232
233 pub fn response(&self) -> ArrayView1<'_, f64> {
235 self.y.view()
236 }
237
238 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 pub fn n_nonzero(&self) -> usize {
249 self.n_nonzero
250 }
251
252 pub fn effective_df(&self) -> f64 {
255 self.n_nonzero as f64 + if self.has_intercept() { 1.0 } else { 0.0 }
256 }
257
258 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 pub fn aic(&self) -> f64 {
266 -2.0 * self.log_likelihood() + 2.0 * self.effective_df()
267 }
268
269 pub fn bic(&self) -> f64 {
271 -2.0 * self.log_likelihood() + (self.n as f64).ln() * self.effective_df()
272 }
273}
274
275fn 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
286fn 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}