1use std::sync::atomic::{AtomicUsize, Ordering};
2
3use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut1, Zip};
4use rayon::prelude::*;
5use tracing::warn;
6
7const MAX_SWEEPS: usize = 1000;
9const RELATIVE_STEP: f64 = 1e-13;
10const KKT_SLACK: f64 = 1e-10;
11const GAP_EVERY: usize = 32;
12const GAP: f64 = 1e-14;
13
14pub fn lasso(
25 latents: ArrayView2<f64>,
26 signal: ArrayView2<f64>,
27 penalty: f64,
28 loadings: &mut Array2<f64>,
29) {
30 let n_samples = latents.ncols() as f64;
31 let gram = latents.dot(&latents.t()) / n_samples;
32 let correlation = latents.dot(&signal.t()) / n_samples;
33 let full_factor = cholesky(gram.clone());
34 let capped = AtomicUsize::new(0);
35 let channels: Vec<_> = loadings
36 .columns_mut()
37 .into_iter()
38 .zip(correlation.columns())
39 .zip(signal.rows())
40 .collect();
41 let n_channels = channels.len();
42 channels.into_par_iter().for_each(|((mut a, b), x)| {
43 let problem = Problem {
44 latents,
45 gram: &gram,
46 full_factor: full_factor.as_ref(),
47 b,
48 x,
49 penalty,
50 };
51 if !problem.solve(&mut a, MAX_SWEEPS) {
52 capped.fetch_add(1, Ordering::Relaxed);
53 }
54 });
55 let capped = capped.into_inner();
56 if capped > 0 {
57 warn!(
58 capped,
59 n_channels,
60 "lasso hit its sweep limit on some channels, so their loadings are approximate; nearly collinear latents cause this, and fewer latents help"
61 );
62 }
63}
64
65struct Problem<'a> {
67 latents: ArrayView2<'a, f64>,
68 gram: &'a Array2<f64>,
69 full_factor: Option<&'a Array2<f64>>,
71 b: ArrayView1<'a, f64>,
72 x: ArrayView1<'a, f64>,
73 penalty: f64,
74}
75
76impl Problem<'_> {
77 fn solve(&self, a: &mut ArrayViewMut1<f64>, max_sweeps: usize) -> bool {
79 let energy = self.x.dot(&self.x) / self.x.len() as f64;
80 for sweep in 1..=max_sweeps {
81 let (step, largest) = sweep_once(self.gram, self.b, self.penalty, a);
82 if step <= RELATIVE_STEP * largest {
83 return true;
84 }
85 if sweep.is_power_of_two()
86 && let Some(exact) = self.solve_for_signs(a.view())
87 {
88 a.assign(&exact);
89 return true;
90 }
91 if sweep % GAP_EVERY == 0 && self.duality_gap(a.view()) <= GAP * energy {
92 return true;
93 }
94 }
95 false
96 }
97
98 fn solve_for_signs(&self, a: ArrayView1<f64>) -> Option<Array1<f64>> {
102 let support: Vec<usize> = (0..a.len()).filter(|&j| a[j] != 0.0).collect();
103 let mut exact = Array1::zeros(a.len());
104 if !support.is_empty() {
105 let rhs: Vec<f64> = support
106 .iter()
107 .map(|&j| self.b[j] - self.penalty * a[j].signum())
108 .collect();
109 let x = if support.len() == a.len() {
110 cholesky_solve(self.full_factor?, rhs)
111 } else {
112 let g = Array2::from_shape_fn((support.len(), support.len()), |(i, k)| {
113 self.gram[[support[i], support[k]]]
114 });
115 cholesky_solve(&cholesky(g)?, rhs)
116 };
117 for (&j, &v) in support.iter().zip(&x) {
118 if v * a[j] <= 0.0 {
119 return None;
120 }
121 exact[j] = v;
122 }
123 }
124 let scale = self.b.iter().fold(self.penalty, |m, v| m.max(v.abs()));
125 let optimal = (0..a.len()).filter(|&j| a[j] == 0.0).all(|j| {
126 (self.b[j] - self.gram.row(j).dot(&exact)).abs() <= self.penalty + KKT_SLACK * scale
127 });
128 optimal.then_some(exact)
129 }
130
131 fn duality_gap(&self, a: ArrayView1<f64>) -> f64 {
134 let n = self.x.len() as f64;
135 let residual = &self.x - &self.latents.t().dot(&a);
136 let fit = residual.dot(&residual) / n;
137 let largest_correlation = (self.b.to_owned() - self.gram.dot(&a))
138 .iter()
139 .fold(0.0f64, |m, v| m.max(v.abs()));
140 let scale = if largest_correlation > self.penalty {
141 self.penalty / largest_correlation
142 } else {
143 1.0
144 };
145 let l1: f64 = a.iter().map(|v| v.abs()).sum();
146 0.5 * fit * (1.0 + scale * scale) + self.penalty * l1 - scale * residual.dot(&self.x) / n
147 }
148}
149
150fn sweep_once(
152 gram: &Array2<f64>,
153 b: ArrayView1<f64>,
154 penalty: f64,
155 a: &mut ArrayViewMut1<f64>,
156) -> (f64, f64) {
157 let (mut largest, mut step) = (0.0f64, 0.0f64);
158 for j in 0..a.len() {
159 let g = gram[[j, j]];
160 let old = a[j];
161 let new = if g > 0.0 {
162 let mut others = 0.0;
163 Zip::from(gram.row(j))
164 .and(&*a)
165 .for_each(|&gji, &ai| others += gji * ai);
166 soft_threshold(b[j] - others + g * old, penalty) / g
167 } else {
168 0.0
169 };
170 a[j] = new;
171 step = step.max((new - old).abs());
172 largest = largest.max(new.abs());
173 }
174 (step, largest)
175}
176
177fn cholesky(mut g: Array2<f64>) -> Option<Array2<f64>> {
179 let n = g.nrows();
180 let floor = 1e-12 * g.diag().iter().fold(0.0f64, |m, v| m.max(*v));
181 for j in 0..n {
182 let pivot = g[[j, j]] - (0..j).map(|k| g[[j, k]] * g[[j, k]]).sum::<f64>();
183 if pivot <= floor {
184 return None;
185 }
186 g[[j, j]] = pivot.sqrt();
187 for i in j + 1..n {
188 let dot: f64 = (0..j).map(|k| g[[i, k]] * g[[j, k]]).sum();
189 g[[i, j]] = (g[[i, j]] - dot) / g[[j, j]];
190 }
191 }
192 Some(g)
193}
194
195fn cholesky_solve(factor: &Array2<f64>, mut rhs: Vec<f64>) -> Vec<f64> {
197 let n = rhs.len();
198 for i in 0..n {
199 rhs[i] = (rhs[i] - (0..i).map(|k| factor[[i, k]] * rhs[k]).sum::<f64>()) / factor[[i, i]];
200 }
201 for i in (0..n).rev() {
202 rhs[i] =
203 (rhs[i] - (i + 1..n).map(|k| factor[[k, i]] * rhs[k]).sum::<f64>()) / factor[[i, i]];
204 }
205 rhs
206}
207
208fn soft_threshold(value: f64, threshold: f64) -> f64 {
209 value.signum() * (value.abs() - threshold).max(0.0)
210}
211
212#[cfg(test)]
213mod tests {
214 use ndarray::array;
215
216 use super::*;
217
218 #[test]
219 fn soft_threshold_shrinks_towards_zero() {
220 assert_eq!(soft_threshold(3.0, 1.0), 2.0);
221 assert_eq!(soft_threshold(-3.0, 1.0), -2.0);
222 assert_eq!(soft_threshold(0.5, 1.0), 0.0);
223 assert_eq!(soft_threshold(-0.5, 1.0), 0.0);
224 }
225
226 #[test]
227 fn zero_penalty_is_least_squares_and_empty_latents_get_zero() {
228 let z = array![
230 [1.0, 0.0, 1.0, 2.0],
231 [0.0, 1.0, 1.0, -1.0],
232 [0.0, 0.0, 0.0, 0.0]
233 ];
234 let x = array![[2.0, -1.0, 1.0, 5.0]];
235 let mut a = Array2::from_elem((3, 1), 0.3);
236 lasso(z.view(), x.view(), 0.0, &mut a);
237 for (got, want) in a.iter().zip([2.0, -1.0, 0.0]) {
238 assert!((got - want).abs() < 1e-10, "{a:?}");
239 }
240 }
241
242 #[test]
243 fn cholesky_solves_and_refuses_singular_systems() {
244 let factor = cholesky(array![[4.0, 2.0], [2.0, 3.0]]).unwrap();
245 let x = cholesky_solve(&factor, vec![2.0, 5.0]);
246 assert!(
247 (x[0] + 0.5).abs() < 1e-14 && (x[1] - 2.0).abs() < 1e-14,
248 "{x:?}"
249 );
250 assert!(cholesky(array![[1.0, 1.0], [1.0, 1.0]]).is_none());
251 assert!(cholesky(array![[0.0]]).is_none());
252 }
253
254 #[test]
255 fn exact_step_rejects_wrong_signs_and_violated_conditions() {
256 let gram = Array2::eye(3);
258 let factor = cholesky(gram.clone());
259 let latents = Array2::zeros((3, 1));
260 let x = ndarray::arr1(&[0.0]);
261 let solve = |b: [f64; 3], a: [f64; 3]| {
262 let b = ndarray::arr1(&b);
263 let problem = Problem {
264 latents: latents.view(),
265 gram: &gram,
266 full_factor: factor.as_ref(),
267 b: b.view(),
268 x: x.view(),
269 penalty: 0.5,
270 };
271 problem.solve_for_signs(ndarray::arr1(&a).view())
272 };
273 let b = [2.0, 0.3, -0.7];
274 let exact = solve(b, [1.0, 0.0, -1.0]).unwrap();
275 assert!(
276 (exact[0] - 1.5).abs() < 1e-15 && exact[1] == 0.0 && (exact[2] + 0.2).abs() < 1e-15
277 );
278 assert!(
279 solve(b, [1.0, 0.0, 1.0]).is_none(),
280 "wrong sign on the third"
281 );
282 assert!(
283 solve(b, [1.0, 0.0, 0.0]).is_none(),
284 "third must be nonzero: |−0.7| > 0.5"
285 );
286 assert_eq!(
287 solve([0.1, -0.2, 0.0], [0.0; 3]).unwrap(),
288 ndarray::arr1(&[0.0; 3])
289 );
290 let all = solve([2.0, 0.7, -0.7], [1.0, 1.0, -1.0]).unwrap();
291 assert!(
292 (all[1] - 0.2).abs() < 1e-15,
293 "full support uses the shared factor: {all:?}"
294 );
295 }
296
297 struct Data {
299 latents: Array2<f64>,
300 gram: Array2<f64>,
301 factor: Option<Array2<f64>>,
302 b: Array1<f64>,
303 x: Array1<f64>,
304 }
305
306 impl Data {
307 fn new(latents: Array2<f64>, x: Array1<f64>) -> Self {
308 let n = latents.ncols() as f64;
309 let gram = latents.dot(&latents.t()) / n;
310 let b = latents.dot(&x) / n;
311 Self {
312 factor: cholesky(gram.clone()),
313 latents,
314 gram,
315 b,
316 x,
317 }
318 }
319
320 fn problem(&self, penalty: f64) -> Problem<'_> {
321 Problem {
322 latents: self.latents.view(),
323 gram: &self.gram,
324 full_factor: self.factor.as_ref(),
325 b: self.b.view(),
326 x: self.x.view(),
327 penalty,
328 }
329 }
330
331 fn objective(&self, a: &Array1<f64>, penalty: f64) -> f64 {
332 let r = &self.x - &self.latents.t().dot(a);
333 r.dot(&r) / (2.0 * self.x.len() as f64)
334 + penalty * a.iter().map(|v| v.abs()).sum::<f64>()
335 }
336 }
337
338 fn correlated() -> Data {
339 let latents = Array2::from_shape_fn((3, 50), |(l, t)| {
340 let t = t as f64;
341 [
342 (0.3 * t).sin(),
343 (0.3 * t).sin() + 0.05 * (1.1 * t).cos(),
344 (0.07 * t).cos(),
345 ][l]
346 });
347 let x = ndarray::Array1::from_shape_fn(50, |t| {
348 2.0 * (0.3 * t as f64).sin() - 0.4 * (0.07 * t as f64).cos()
349 });
350 Data::new(latents, x)
351 }
352
353 #[test]
354 fn a_sweep_updates_each_coordinate_against_the_others() {
355 let gram = array![[2.0, 1.0], [1.0, 2.0]];
356 let b = array![3.0, 0.0];
357 let mut a = array![0.0, 0.0];
358 let (step, largest) = sweep_once(&gram, b.view(), 0.5, &mut a.view_mut());
359 assert_eq!(a, array![1.25, -0.375]);
361 assert_eq!((step, largest), (1.25, 1.25));
362
363 let mut warm = array![1.0, -0.375];
367 let (step, _) = sweep_once(&gram, b.view(), 0.5, &mut warm.view_mut());
368 assert_eq!((warm, step), (array![1.4375, -0.46875], 0.4375));
369 }
370
371 #[test]
372 fn duality_gap_is_primal_minus_dual_and_closes_at_the_minimum() {
373 let data = correlated();
374 for (penalty, a) in [(0.01, array![0.5, -0.2, 0.1]), (5.0, array![0.0, 0.0, 0.0])] {
375 let problem = data.problem(penalty);
376 let n = data.x.len() as f64;
377 let r = &data.x - &data.latents.t().dot(&a);
378 let correlation = (&data.b - &data.gram.dot(&a))
379 .iter()
380 .fold(0.0f64, |m, v| m.max(v.abs()));
381 let s = if correlation > penalty {
382 penalty / correlation
383 } else {
384 1.0
385 };
386 let dual =
387 (data.x.dot(&data.x) - (&data.x - &(&r * s)).mapv(|v| v * v).sum()) / (2.0 * n);
388 let expected = data.objective(&a, penalty) - dual;
389 assert!(
390 (problem.duality_gap(a.view()) - expected).abs() < 1e-12,
391 "penalty {penalty}"
392 );
393 }
394 let problem = data.problem(0.01);
395 let mut a = Array1::zeros(3);
396 assert!(problem.solve(&mut a.view_mut(), MAX_SWEEPS));
397 assert!(problem.duality_gap(a.view()).abs() < 1e-12);
398 }
399
400 #[test]
401 fn optimality_check_uses_the_full_gradient_within_a_small_slack() {
402 let gram = array![[1.0, 0.5], [0.5, 1.0]];
403 let factor = cholesky(gram.clone());
404 let (latents, x) = (Array2::zeros((2, 1)), ndarray::arr1(&[0.0]));
405 let check = |b1: f64| {
406 let b = array![2.5, b1];
407 let problem = Problem {
408 latents: latents.view(),
409 gram: &gram,
410 full_factor: factor.as_ref(),
411 b: b.view(),
412 x: x.view(),
413 penalty: 0.5,
414 };
415 problem.solve_for_signs(array![1.0, 0.0].view())
416 };
417 assert_eq!(
419 check(1.5).unwrap(),
420 array![2.0, 0.0],
421 "gradient exactly at the penalty"
422 );
423 assert!(
424 check(1.5 + 1e-10).is_some(),
425 "within 1e-10 of the largest |b|"
426 );
427 assert!(check(1.5 + 1e-9).is_none());
428 assert!(check(0.5 - 1e-9).is_none(), "gradient −0.5 − 1e-9");
429
430 let gram = array![[4.0, 1.0], [1.0, 1.0]];
432 let factor = cholesky(gram.clone());
433 let b = array![2.3, 2.5];
434 let problem = Problem {
435 latents: latents.view(),
436 gram: &gram,
437 full_factor: factor.as_ref(),
438 b: b.view(),
439 x: x.view(),
440 penalty: 0.5,
441 };
442 assert_eq!(
443 problem.solve_for_signs(array![0.0, 1.0].view()).unwrap(),
444 array![0.0, 2.0]
445 );
446 }
447
448 #[test]
449 fn the_sweep_budget_is_reported_and_a_larger_one_reaches_the_minimum() {
450 let data = correlated();
452 let problem = data.problem(0.01);
453 let mut a = array![-5.0, 5.0, 0.0];
454 assert!(!problem.solve(&mut a.view_mut(), 1));
455 assert!(
456 !problem.solve(&mut a.view_mut(), MAX_SWEEPS),
457 "still crawling after 1000 more"
458 );
459 assert!(problem.solve(&mut a.view_mut(), 1_000_000));
460 let mut from_zero = Array1::zeros(3);
461 assert!(problem.solve(&mut from_zero.view_mut(), MAX_SWEEPS));
462 assert!((data.objective(&a, 0.01) - data.objective(&from_zero, 0.01)).abs() < 1e-12);
463 }
464
465 #[test]
466 fn cholesky_floor_is_relative_to_the_diagonal() {
467 assert!(cholesky(array![[1e6, 1e6], [1e6, 1e6 + 1e-7]]).is_none());
468 assert!(cholesky(array![[1e-6, 0.0], [0.0, 1e-6]]).is_some());
469 }
470
471 #[test]
472 fn large_penalty_gives_all_zeros() {
473 let z = array![[1.0, 2.0, 3.0], [3.0, 1.0, 0.0]];
474 let x = array![[1.0, 1.0, 1.0], [0.0, 2.0, 1.0]];
475 let mut a = Array2::from_elem((2, 2), 1.0);
476 lasso(z.view(), x.view(), 1e6, &mut a);
477 assert!(a.iter().all(|&v| v == 0.0));
478 }
479}