1use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ShapeBuilder};
2
3use super::{center, Centered, Regressor};
4use crate::error::{DmlError, Result};
5
6#[derive(Debug, Clone)]
13pub struct Lasso {
14 alpha: f64,
15 tol: f64,
16 max_iter: usize,
17 coef: Option<Array1<f64>>,
18 intercept: f64,
19}
20
21impl Lasso {
22 pub fn new(alpha: f64) -> Self {
23 Self {
24 alpha: alpha.max(0.0),
25 tol: 1e-4,
26 max_iter: 100_000,
27 coef: None,
28 intercept: 0.0,
29 }
30 }
31
32 pub fn tol(mut self, tol: f64) -> Self {
37 self.tol = tol;
38 self
39 }
40
41 pub fn max_iter(mut self, max_iter: usize) -> Self {
42 self.max_iter = max_iter;
43 self
44 }
45
46 pub fn coef(&self) -> Option<&Array1<f64>> {
47 self.coef.as_ref()
48 }
49
50 pub fn intercept(&self) -> f64 {
51 self.intercept
52 }
53}
54
55fn soft_threshold(z: f64, gamma: f64) -> f64 {
56 if z > gamma {
57 z - gamma
58 } else if z < -gamma {
59 z + gamma
60 } else {
61 0.0
62 }
63}
64
65struct Gap {
67 gap: f64,
68 xt_r: Array1<f64>,
69 dual_norm: f64,
70}
71
72struct CdDesign {
75 x_mean: Array1<f64>,
76 y_mean: f64,
77 xc: Array2<f64>,
78 yc: Array1<f64>,
79 col_sq: Vec<f64>,
80 y_norm2: f64,
81 gram: Option<Array2<f64>>,
84 xty: Array1<f64>,
86}
87
88impl CdDesign {
89 fn new(
90 x: ArrayView2<'_, f64>,
91 y: ArrayView1<'_, f64>,
92 w: Option<ArrayView1<'_, f64>>,
93 ) -> Result<Self> {
94 let Centered {
95 x_mean,
96 y_mean,
97 xc: centered,
98 yc,
99 } = center(x, y, w, true)?;
100 let mut xc = Array2::<f64>::zeros(centered.dim().f());
101 xc.assign(¢ered);
102 let col_sq = xc.columns().into_iter().map(|c| c.dot(&c)).collect();
103 let (n, p) = xc.dim();
104 let gram = (n > p).then(|| xc.t().dot(&xc));
105 let xty = xc.t().dot(&yc);
106 Ok(Self {
107 x_mean,
108 y_mean,
109 y_norm2: yc.dot(&yc),
110 xc,
111 yc,
112 col_sq,
113 gram,
114 xty,
115 })
116 }
117
118 fn initial_state(&self) -> Array1<f64> {
121 match self.gram {
122 Some(_) => self.xty.clone(),
123 None => self.yc.clone(),
124 }
125 }
126
127 fn duality_gap(&self, n_alpha: f64, w: &Array1<f64>, state: &Array1<f64>) -> Gap {
132 let (xt_r, r_norm2, r_y) = match self.gram {
133 Some(_) => {
134 let w_xty = w.dot(&self.xty);
135 let w_gw = w_xty - w.dot(state);
137 (
138 state.clone(),
139 self.y_norm2 - 2.0 * w_xty + w_gw,
140 self.y_norm2 - w_xty,
141 )
142 }
143 None => (
144 self.xc.t().dot(state),
145 state.dot(state),
146 state.dot(&self.yc),
147 ),
148 };
149 if n_alpha == 0.0 {
150 return Gap {
151 gap: xt_r.dot(&xt_r),
152 xt_r,
153 dual_norm: 0.0,
154 };
155 }
156 let dual_norm = xt_r.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
157 let scale = if dual_norm > n_alpha {
158 n_alpha / dual_norm
159 } else {
160 1.0
161 };
162 let primal = 0.5 * r_norm2 + n_alpha * w.iter().map(|v| v.abs()).sum::<f64>();
163 let dual = -0.5 * scale * scale * r_norm2 + scale * r_y;
164 Gap {
165 gap: primal - dual,
166 xt_r,
167 dual_norm,
168 }
169 }
170
171 fn screen(
176 &self,
177 n_alpha: f64,
178 g: &Gap,
179 active: &mut [bool],
180 w: &mut Array1<f64>,
181 state: &mut Array1<f64>,
182 ) {
183 let radius = (2.0 * g.gap.abs()).sqrt() / n_alpha;
184 for j in 0..active.len() {
185 if !active[j] {
186 continue;
187 }
188 let xj_theta = g.xt_r[j] / n_alpha.max(g.dual_norm);
189 let d_j = (1.0 - xj_theta.abs()) / self.col_sq[j].sqrt();
190 if self.col_sq[j] == 0.0 || d_j > radius {
191 active[j] = false;
192 if w[j] != 0.0 {
193 self.update(j, w[j], state);
194 w[j] = 0.0;
195 }
196 }
197 }
198 }
199
200 fn update(&self, j: usize, delta: f64, state: &mut Array1<f64>) {
202 match &self.gram {
203 Some(g) => state.scaled_add(delta, &g.row(j)),
205 None => state.scaled_add(delta, &self.xc.column(j)),
206 }
207 }
208
209 fn solve(
215 &self,
216 alpha: f64,
217 w: &mut Array1<f64>,
218 state: &mut Array1<f64>,
219 tol: f64,
220 max_iter: usize,
221 ) -> Result<()> {
222 let p = self.xc.ncols();
223 let n_alpha = self.xc.nrows() as f64 * alpha;
224 let tol_gap = tol * self.y_norm2;
225 let screening = n_alpha > 0.0;
226 let g = self.duality_gap(n_alpha, w, state);
227 if g.gap <= tol_gap && (self.gram.is_none() || g.gap >= 0.0) {
228 return Ok(());
229 }
230 let mut active = vec![true; p];
231 if screening {
232 self.screen(n_alpha, &g, &mut active, w, state);
233 }
234 for iter in 0..max_iter {
235 let mut max_change = 0.0_f64;
236 let mut max_w = 0.0_f64;
237 for j in 0..p {
238 if !active[j] || self.col_sq[j] == 0.0 {
239 continue;
240 }
241 let old = w[j];
242 let corr = match self.gram {
243 Some(_) => state[j],
244 None => self.xc.column(j).dot(&*state),
245 };
246 let new = soft_threshold(corr + old * self.col_sq[j], n_alpha) / self.col_sq[j];
247 if new != old {
248 self.update(j, old - new, state);
249 w[j] = new;
250 }
251 max_change = max_change.max((new - old).abs());
252 max_w = max_w.max(new.abs());
253 }
254 if max_w == 0.0 || max_change <= tol * max_w || iter + 1 == max_iter {
255 let g = self.duality_gap(n_alpha, w, state);
256 if g.gap <= tol_gap {
257 return Ok(());
258 }
259 if screening {
260 self.screen(n_alpha, &g, &mut active, w, state);
261 }
262 }
263 }
264 Err(DmlError::Convergence(format!(
265 "Lasso did not converge in {max_iter} sweeps"
266 )))
267 }
268
269 fn intercept(&self, w: &Array1<f64>) -> f64 {
270 self.y_mean - self.x_mean.dot(w)
271 }
272}
273
274pub(super) fn lasso_path(
277 x: ArrayView2<'_, f64>,
278 y: ArrayView1<'_, f64>,
279 w: Option<ArrayView1<'_, f64>>,
280 alphas: &[f64],
281 tol: f64,
282 max_iter: usize,
283) -> Result<Vec<(Array1<f64>, f64)>> {
284 let design = CdDesign::new(x, y, w)?;
285 let mut coef = Array1::<f64>::zeros(x.ncols());
286 let mut state = design.initial_state();
287 alphas
288 .iter()
289 .map(|&alpha| {
290 design.solve(alpha, &mut coef, &mut state, tol, max_iter)?;
291 Ok((coef.clone(), design.intercept(&coef)))
292 })
293 .collect()
294}
295
296impl Lasso {
297 fn fit_impl(
298 &mut self,
299 x: ArrayView2<'_, f64>,
300 y: ArrayView1<'_, f64>,
301 w: Option<ArrayView1<'_, f64>>,
302 ) -> Result<()> {
303 let (coef, intercept) = lasso_path(x, y, w, &[self.alpha], self.tol, self.max_iter)?
304 .pop()
305 .expect("one alpha");
306 self.intercept = intercept;
307 self.coef = Some(coef);
308 Ok(())
309 }
310}
311
312impl Regressor for Lasso {
313 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
314 self.fit_impl(x, y, None)
315 }
316
317 fn fit_weighted(
320 &mut self,
321 x: ArrayView2<'_, f64>,
322 y: ArrayView1<'_, f64>,
323 w: ArrayView1<'_, f64>,
324 ) -> Result<()> {
325 self.fit_impl(x, y, Some(w))
326 }
327
328 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
329 let coef = self.coef.as_ref().ok_or(DmlError::NotFitted)?;
330 if x.ncols() != coef.len() {
331 return Err(DmlError::DimensionMismatch(
332 "Lasso::predict feature count".into(),
333 ));
334 }
335 Ok(x.dot(coef) + self.intercept)
336 }
337
338 fn fresh(&self) -> Box<dyn Regressor> {
339 Box::new(Self::new(self.alpha).tol(self.tol).max_iter(self.max_iter))
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::learner::Ols;
347 use ndarray::array;
348
349 fn data() -> (Array2<f64>, Array1<f64>) {
350 let x = Array2::from_shape_fn((40, 3), |(i, j)| {
351 (((i + 1) * (j + 2) * 7) % 13) as f64 - 6.0
352 });
353 let y = x
354 .rows()
355 .into_iter()
356 .map(|r| 2.0 + 1.5 * r[0] - 0.2 * r[2])
357 .collect();
358 (x, y)
359 }
360
361 #[test]
362 fn zero_alpha_equals_ols() {
363 let (x, y) = data();
364 let mut lasso = Lasso::new(0.0).tol(1e-12);
365 lasso.fit(x.view(), y.view()).unwrap();
366 let mut ols = Ols::new();
367 ols.fit(x.view(), y.view()).unwrap();
368 let diff = lasso.coef().unwrap() - ols.coef().unwrap();
369 assert!(diff.iter().all(|v| v.abs() < 1e-8));
370 }
371
372 #[test]
373 fn satisfies_kkt_conditions() {
374 let (x, y) = data();
375 let alpha = 0.5;
376 let mut m = Lasso::new(alpha).tol(1e-12);
377 m.fit(x.view(), y.view()).unwrap();
378 let resid = &y - &m.predict(x.view()).unwrap();
379 let n = x.nrows() as f64;
380 for (j, &wj) in m.coef().unwrap().iter().enumerate() {
381 let g = x.column(j).dot(&resid) / n;
382 if wj != 0.0 {
383 assert!((g - alpha * wj.signum()).abs() < 1e-8, "active {j}: {g}");
384 } else {
385 assert!(g.abs() <= alpha + 1e-8, "inactive {j}: {g}");
386 }
387 }
388 }
389
390 #[test]
391 fn large_alpha_gives_intercept_only() {
392 let (x, y) = data();
393 let mut m = Lasso::new(1e6);
394 m.fit(x.view(), y.view()).unwrap();
395 assert!(m.coef().unwrap().iter().all(|&v| v == 0.0));
396 assert!((m.intercept() - y.mean().unwrap()).abs() < 1e-12);
397 assert_eq!(
398 m.predict(array![[0.0, 0.0, 0.0]].view()).unwrap()[0],
399 m.intercept()
400 );
401 }
402
403 #[test]
404 fn warm_started_path_equals_separate_fits() {
405 let x = Array2::from_shape_fn((50, 4), |(i, j)| {
406 (((i * 7 + j * 3) % 11) as f64 - 5.0) / 3.0
407 });
408 let y: Array1<f64> = x
409 .rows()
410 .into_iter()
411 .map(|r| 1.0 + r[0] - 0.5 * r[3])
412 .collect();
413 let alphas = [1.0, 0.3, 0.1, 0.01];
414 let path = lasso_path(x.view(), y.view(), None, &alphas, 1e-13, 100_000).unwrap();
415 for (&alpha, (coef, intercept)) in alphas.iter().zip(&path) {
416 let mut m = Lasso::new(alpha).tol(1e-13);
417 m.fit(x.view(), y.view()).unwrap();
418 let diff = (m.coef().unwrap() - coef).mapv(f64::abs).sum();
419 assert!(
420 diff < 1e-9 && (m.intercept() - intercept).abs() < 1e-9,
421 "alpha {alpha}"
422 );
423 }
424 }
425}