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-12,
26            max_iter: 100_000,
27            coef: None,
28            intercept: 0.0,
29        }
30    }
31
32    /// Stop when no coefficient moves by more than `tol · max|w|` in a sweep.
33    pub fn tol(mut self, tol: f64) -> Self {
34        self.tol = tol;
35        self
36    }
37
38    pub fn max_iter(mut self, max_iter: usize) -> Self {
39        self.max_iter = max_iter;
40        self
41    }
42
43    pub fn coef(&self) -> Option<&Array1<f64>> {
44        self.coef.as_ref()
45    }
46
47    pub fn intercept(&self) -> f64 {
48        self.intercept
49    }
50}
51
52fn soft_threshold(z: f64, gamma: f64) -> f64 {
53    if z > gamma {
54        z - gamma
55    } else if z < -gamma {
56        z + gamma
57    } else {
58        0.0
59    }
60}
61
62impl Lasso {
63    fn fit_impl(
64        &mut self,
65        x: ArrayView2<'_, f64>,
66        y: ArrayView1<'_, f64>,
67        w: Option<ArrayView1<'_, f64>>,
68    ) -> Result<()> {
69        let (n, p) = x.dim();
70        let Centered {
71            x_mean,
72            y_mean,
73            xc: centered,
74            yc: mut resid,
75        } = center(x, y, w, true)?;
76        // Column-major copy so each coordinate update reads a contiguous column.
77        let mut xc = Array2::<f64>::zeros((n, p).f());
78        xc.assign(&centered);
79        let col_sq: Vec<f64> = xc.columns().into_iter().map(|c| c.dot(&c)).collect();
80        let n_alpha = n as f64 * self.alpha;
81        let mut w = Array1::<f64>::zeros(p);
82
83        let mut converged = p == 0;
84        for _ in 0..self.max_iter {
85            if converged {
86                break;
87            }
88            let mut max_change = 0.0_f64;
89            let mut max_w = 0.0_f64;
90            for j in 0..p {
91                if col_sq[j] == 0.0 {
92                    continue;
93                }
94                let col = xc.column(j);
95                let old = w[j];
96                let rho = col.dot(&resid) + old * col_sq[j];
97                let new = soft_threshold(rho, n_alpha) / col_sq[j];
98                if new != old {
99                    resid.scaled_add(old - new, &col);
100                    w[j] = new;
101                }
102                max_change = max_change.max((new - old).abs());
103                max_w = max_w.max(new.abs());
104            }
105            converged = max_w == 0.0 || max_change <= self.tol * max_w;
106        }
107        if !converged {
108            return Err(DmlError::Convergence(format!(
109                "Lasso did not converge in {} sweeps",
110                self.max_iter
111            )));
112        }
113        self.intercept = y_mean - x_mean.dot(&w);
114        self.coef = Some(w);
115        Ok(())
116    }
117}
118
119impl Regressor for Lasso {
120    fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
121        self.fit_impl(x, y, None)
122    }
123
124    /// Minimises `1/(2n)·Σ wᵢ rᵢ² + α‖β‖₁` with weights rescaled to sum to
125    /// `n`, as scikit-learn's `Lasso.fit(sample_weight=w)`.
126    fn fit_weighted(
127        &mut self,
128        x: ArrayView2<'_, f64>,
129        y: ArrayView1<'_, f64>,
130        w: ArrayView1<'_, f64>,
131    ) -> Result<()> {
132        self.fit_impl(x, y, Some(w))
133    }
134
135    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
136        let coef = self.coef.as_ref().ok_or(DmlError::NotFitted)?;
137        if x.ncols() != coef.len() {
138            return Err(DmlError::DimensionMismatch(
139                "Lasso::predict feature count".into(),
140            ));
141        }
142        Ok(x.dot(coef) + self.intercept)
143    }
144
145    fn fresh(&self) -> Box<dyn Regressor> {
146        Box::new(Self::new(self.alpha).tol(self.tol).max_iter(self.max_iter))
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::learner::Ols;
154    use ndarray::array;
155
156    fn data() -> (Array2<f64>, Array1<f64>) {
157        let x = Array2::from_shape_fn((40, 3), |(i, j)| {
158            (((i + 1) * (j + 2) * 7) % 13) as f64 - 6.0
159        });
160        let y = x
161            .rows()
162            .into_iter()
163            .map(|r| 2.0 + 1.5 * r[0] - 0.2 * r[2])
164            .collect();
165        (x, y)
166    }
167
168    #[test]
169    fn zero_alpha_equals_ols() {
170        let (x, y) = data();
171        let mut lasso = Lasso::new(0.0);
172        lasso.fit(x.view(), y.view()).unwrap();
173        let mut ols = Ols::new();
174        ols.fit(x.view(), y.view()).unwrap();
175        let diff = lasso.coef().unwrap() - ols.coef().unwrap();
176        assert!(diff.iter().all(|v| v.abs() < 1e-8));
177    }
178
179    #[test]
180    fn satisfies_kkt_conditions() {
181        let (x, y) = data();
182        let alpha = 0.5;
183        let mut m = Lasso::new(alpha);
184        m.fit(x.view(), y.view()).unwrap();
185        let resid = &y - &m.predict(x.view()).unwrap();
186        let n = x.nrows() as f64;
187        for (j, &wj) in m.coef().unwrap().iter().enumerate() {
188            let g = x.column(j).dot(&resid) / n;
189            if wj != 0.0 {
190                assert!((g - alpha * wj.signum()).abs() < 1e-8, "active {j}: {g}");
191            } else {
192                assert!(g.abs() <= alpha + 1e-8, "inactive {j}: {g}");
193            }
194        }
195    }
196
197    #[test]
198    fn large_alpha_gives_intercept_only() {
199        let (x, y) = data();
200        let mut m = Lasso::new(1e6);
201        m.fit(x.view(), y.view()).unwrap();
202        assert!(m.coef().unwrap().iter().all(|&v| v == 0.0));
203        assert!((m.intercept() - y.mean().unwrap()).abs() < 1e-12);
204        assert_eq!(
205            m.predict(array![[0.0, 0.0, 0.0]].view()).unwrap()[0],
206            m.intercept()
207        );
208    }
209}