modewise_vlmd/
lasso.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2
3use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut1, Zip};
4use rayon::prelude::*;
5use tracing::warn;
6
7/// The reference's per-call budget (scikit-learn `max_iter=1000`).
8const 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
14/// Sparse loadings for every channel: column `c` of `loadings` (latent × channel) minimises
15/// `(1/2T)‖x_c − Zᵀa‖² + penalty·‖a‖₁`, where `latents` is Z (latent × sample) and `signal`
16/// is X (channel × sample). This is scikit-learn's `Lasso(alpha=penalty, fit_intercept=False)`.
17///
18/// Cyclic coordinate descent on the Gram matrix, warm-started from `loadings`, one channel per
19/// thread. A channel stops at the first of:
20/// - after sweeps 1, 2, 4, 8, …: the optimality conditions, solved exactly for the current signs, hold
21/// - no coefficient moves by more than 1e-13 of the largest one
22/// - every 32 sweeps: the duality gap is at most 1e-14 of the channel's mean power
23/// - 1000 sweeps. Nearly collinear latents can need more; a warning then says how many channels stopped early.
24pub 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
65/// One channel's lasso: minimise `(1/2T)‖x − Zᵀa‖² + penalty·‖a‖₁`, with `G = ZZᵀ/T` and `b = Zx/T`.
66struct Problem<'a> {
67    latents: ArrayView2<'a, f64>,
68    gram: &'a Array2<f64>,
69    /// Cholesky factor of the whole of G, shared by every channel whose loadings are all nonzero.
70    full_factor: Option<&'a Array2<f64>>,
71    b: ArrayView1<'a, f64>,
72    x: ArrayView1<'a, f64>,
73    penalty: f64,
74}
75
76impl Problem<'_> {
77    /// Returns false when the sweep limit stopped it.
78    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    /// The lasso solution with the nonzero pattern and signs of `a`, if one exists: it solves
99    /// `G_SS x = b_S − penalty·sign(a_S)`, keeps every sign, and leaves `|b_j − G_j x| ≤ penalty`
100    /// for every zero coefficient. These are the optimality conditions, so the result is the minimum.
101    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    /// scikit-learn's duality gap for the lasso, scaled by 1/T; zero at the minimum. With
132    /// near-collinear latents the minimiser is poorly determined but the gap still closes.
133    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
150/// One cyclic pass. Returns the largest coefficient change and the largest coefficient.
151fn 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
177/// Lower Cholesky factor of a symmetric positive definite `g`; `None` when `g` is (nearly) singular.
178fn 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
195/// Solves `L Lᵀ x = rhs` for a lower Cholesky factor `L`.
196fn 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        // x = 2 z0 − z1, exactly; z2 is all zeros.
229        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        // Orthonormal design: the solution is the soft-thresholded correlation [1.5, 0, −0.2].
257        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    /// A problem built from latents and one channel, owning what `Problem` borrows.
298    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        // a0 = S(3, 0.5)/2 = 1.25; a1 = S(0 − 1·1.25, 0.5)/2 = −0.375.
360        assert_eq!(a, array![1.25, -0.375]);
361        assert_eq!((step, largest), (1.25, 1.25));
362
363        // From a warm start the step is the change, not the new value:
364        // a0 = S(3 − (2·1 − 0.375) + 2·1, 0.5)/2 = 1.4375, a change of 0.4375;
365        // a1 = S(0 − (1.4375 − 0.75) − 0.75, 0.5)/2 = −0.46875.
366        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        // a0 = 2.5 − 0.5 = 2; the zero coefficient's gradient is b1 − 0.5·2 = b1 − 1.
418        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        // A support that is not the leading block needs its own factor.
431        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        // Nearly collinear latents and a start with the wrong signs: coordinate descent crawls.
451        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}