Skip to main content

dml_core/learner/
lasso.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ShapeBuilder};
2
3use super::{center, Centered, Regressor};
4use crate::error::{DmlError, Result};
5
6/// Lasso regression with an unpenalised intercept, fitted by cyclic
7/// coordinate descent.
8///
9/// Minimises `1/(2n)·‖y − b − Xw‖² + α‖w‖₁`, the same objective as
10/// scikit-learn's `Lasso(alpha=α)`. The classic DML learner for
11/// high-dimensional confounders.
12#[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    /// Stopping rule of scikit-learn's `Lasso(tol=tol)` (default `1e-4`): once
33    /// no coefficient moves by more than `tol · max|w|` in a sweep, stop if the
34    /// duality gap, which bounds the distance to the optimal objective, is at
35    /// most `tol · ‖y − ȳ‖² / n`. With `α = 0` only the first condition applies.
36    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
65/// A duality gap with the quantities screening reuses.
66struct Gap {
67    gap: f64,
68    xt_r: Array1<f64>,
69    dual_norm: f64,
70}
71
72/// Centred design in column-major order (each coordinate update reads a
73/// contiguous column) with the column norms, shared by all fits on a path.
74struct 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    /// `xcᵀxc` when `n > p` (as scikit-learn's `precompute="auto"`): each
82    /// coordinate update then costs `O(p)` instead of `O(n)`.
83    gram: Option<Array2<f64>>,
84    /// `xcᵀyc`, kept with the Gram matrix for the duality gap.
85    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(&centered);
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    /// Solver state at `w = 0`: the residual `yc − xc·w`, or with the Gram
119    /// matrix the correlations `xcᵀ(yc − xc·w)`.
120    fn initial_state(&self) -> Array1<f64> {
121        match self.gram {
122            Some(_) => self.xty.clone(),
123            None => self.yc.clone(),
124        }
125    }
126
127    /// Duality gap of `½‖r‖² + nα‖w‖₁` at `w`, with `xcᵀr` and its largest
128    /// absolute entry (scikit-learn's `gap_enet`): the dual point is the
129    /// residual rescaled to be feasible. Without a penalty there is no useful
130    /// gap, and `‖xcᵀr‖²` (the squared gradient) takes its place.
131    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                // ‖r‖² = ‖y‖² − 2wᵀxᵀy + wᵀGw, with Gw = xᵀy − xᵀr.
136                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    /// Gap safe screening (Fercoq, Gramfort & Salmon, 2015), as scikit-learn:
172    /// coordinate `j` is provably zero at the optimum when
173    /// `(1 − |x_jᵀθ|) / ‖x_j‖ > √(2·gap) / (nα)` for the dual point `θ`; it is
174    /// set to zero and left out of further sweeps.
175    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    /// Keep `state` in step with `w[j]` decreasing by `delta`.
201    fn update(&self, j: usize, delta: f64, state: &mut Array1<f64>) {
202        match &self.gram {
203            // The Gram matrix is symmetric: row j is column j, contiguous.
204            Some(g) => state.scaled_add(delta, &g.row(j)),
205            None => state.scaled_add(delta, &self.xc.column(j)),
206        }
207    }
208
209    /// Cyclic coordinate descent for penalty `alpha`, warm-started from `w`;
210    /// `state` (see [`initial_state`](Self::initial_state)) must match `w` on
211    /// entry and is kept up to date. Follows scikit-learn's `cd_fast`: stop at
212    /// once if the warm start is already within tolerance, screen coordinates
213    /// after every gap evaluation.
214    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
274/// Lasso solutions `(coef, intercept)` along `alphas` (best in decreasing
275/// order), each fit warm-started from the previous one.
276pub(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    /// Minimises `1/(2n)·Σ wᵢ rᵢ² + α‖β‖₁` with weights rescaled to sum to
318    /// `n`, as scikit-learn's `Lasso.fit(sample_weight=w)`.
319    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}