Skip to main content

oxicuda_cs/lasso/
dantzig.rs

1//! Dantzig selector via iterative reweighted L1 / ADMM — lasso-module variant.
2//!
3//! Solves `min ||x||_1  s.t.  ||Aᵀ(b − Ax)||_∞ ≤ delta`
4//!
5//! using a scaled-form ADMM iteration:
6//!
7//! 1. Precompute `M = AᵀA` and `q = Aᵀb`.
8//! 2. Factorize `(M + ρI)` via Cholesky (ρ = 1.0).
9//! 3. Iterate:
10//!    a. **x-update**: solve `(M + ρI) x_new = ρ(q − s + u)` then soft-threshold.
11//!    b. Check constraint `||Aᵀ(b − Ax)||_∞ ≤ delta`.
12//!    c. **s-update**: project dual variable onto `[-delta, delta]`.
13//!    d. **u-update**: dual residual accumulation.
14//! 4. Stop when constraint is satisfied within `tol` and primal change is small.
15
16use crate::error::{CsError, CsResult};
17use crate::linalg::cholesky::{cholesky_factor, cholesky_solve};
18use crate::linalg::{mat_t_vec, mat_vec, norm2};
19use crate::thresholding::iht::soft_threshold;
20
21/// Configuration for the lasso-module Dantzig selector ([`dantzig_selector`]).
22#[derive(Debug, Clone)]
23pub struct DantzigConfig {
24    /// Noise tolerance δ: we seek `||Aᵀ(b − Ax)||_∞ ≤ delta`.
25    pub delta: f64,
26    /// Maximum number of ADMM iterations.
27    pub max_iter: usize,
28    /// Convergence tolerance for stopping criterion.
29    pub tol: f64,
30}
31
32/// Dantzig selector (lasso-module variant) via ADMM with hardcoded `ρ = 1.0`.
33///
34/// # Arguments
35/// - `a`: `[m × n]` measurement matrix in row-major order.
36/// - `b`: `[m]` observation vector.
37/// - `m`: number of rows (measurements).
38/// - `n`: number of columns (signal dimension).
39/// - `cfg`: algorithm configuration.
40///
41/// # Returns
42/// A length-`n` sparse estimate that approximately satisfies `||Aᵀ(b − Ax)||_∞ ≤ delta`.
43///
44/// # Errors
45/// - [`CsError::EmptyInput`] if `m == 0` or `n == 0`.
46/// - [`CsError::ShapeMismatch`] if `a.len() != m * n`.
47/// - [`CsError::DimensionMismatch`] if `b.len() != m`.
48/// - [`CsError::InvalidParameter`] if `cfg.delta <= 0` or `cfg.tol <= 0`.
49/// - [`CsError::SingularMatrix`] if `AᵀA + ρI` is not positive-definite.
50pub fn dantzig_selector(
51    a: &[f64],
52    b: &[f64],
53    m: usize,
54    n: usize,
55    cfg: &DantzigConfig,
56) -> CsResult<Vec<f64>> {
57    // ── Input validation ─────────────────────────────────────────────────────
58    if m == 0 || n == 0 {
59        return Err(CsError::EmptyInput);
60    }
61    if a.len() != m * n {
62        return Err(CsError::ShapeMismatch {
63            expected: vec![m, n],
64            got: vec![a.len()],
65        });
66    }
67    if b.len() != m {
68        return Err(CsError::DimensionMismatch { a: b.len(), b: m });
69    }
70    if cfg.delta <= 0.0 {
71        return Err(CsError::InvalidParameter(format!(
72            "delta must be > 0, got {}",
73            cfg.delta
74        )));
75    }
76    if cfg.tol <= 0.0 {
77        return Err(CsError::InvalidParameter(format!(
78            "tol must be > 0, got {}",
79            cfg.tol
80        )));
81    }
82
83    // ── Precomputation ───────────────────────────────────────────────────────
84    // ρ is fixed at 1.0; chosen to give balanced primal/dual steps.
85    let rho = 1.0_f64;
86
87    // M = AᵀA  (n × n, row-major)
88    let mut ata = vec![0.0_f64; n * n];
89    for k in 0..m {
90        for i in 0..n {
91            let aki = a[k * n + i];
92            for j in 0..n {
93                ata[i * n + j] += aki * a[k * n + j];
94            }
95        }
96    }
97
98    // q = Aᵀb  (length n)
99    let at_b = mat_t_vec(a, m, n, b)?;
100
101    // System matrix for x-update: (AᵀA + ρI)
102    let mut system = ata.clone();
103    for i in 0..n {
104        system[i * n + i] += rho;
105    }
106    let l = cholesky_factor(&system, n)?;
107
108    // ── ADMM iterates ────────────────────────────────────────────────────────
109    let mut x = vec![0.0_f64; n];
110    // s lives in R^n and represents the slack for the dual constraint.
111    let mut s = vec![0.0_f64; n];
112    // u: scaled dual variable.
113    let mut u = vec![0.0_f64; n];
114
115    for _iter in 0..cfg.max_iter {
116        // ── x-update ─────────────────────────────────────────────────────────
117        // Solve (AᵀA + ρI) x_hat = ρ(q − s + u)  then apply soft-threshold.
118        let mut rhs = vec![0.0_f64; n];
119        for j in 0..n {
120            rhs[j] = rho * (at_b[j] - s[j] + u[j]);
121        }
122        // Add the ρ x term from the augmented Lagrangian quadratic (proximal gradient viewpoint).
123        for j in 0..n {
124            rhs[j] += rho * x[j];
125        }
126        let x_hat = cholesky_solve(&l, n, &rhs)?;
127        // Soft-threshold with threshold 1/ρ to enforce L1 penalty.
128        let x_new = soft_threshold(&x_hat, 1.0 / rho);
129
130        // ── Constraint residual for s-update ─────────────────────────────────
131        // r_constraint = Aᵀ(b − A x_new)  (length n)
132        let ax_new = mat_vec(a, m, n, &x_new)?;
133        let mut primal_res = vec![0.0_f64; m];
134        for i in 0..m {
135            primal_res[i] = b[i] - ax_new[i];
136        }
137        let at_res = mat_t_vec(a, m, n, &primal_res)?;
138
139        // ── s-update: project (Aᵀ res + u) onto [-delta, delta] ─────────────
140        let mut s_new = vec![0.0_f64; n];
141        for j in 0..n {
142            let v = at_res[j] + u[j];
143            s_new[j] = v.clamp(-cfg.delta, cfg.delta);
144        }
145
146        // ── u-update: dual residual accumulation ─────────────────────────────
147        for j in 0..n {
148            u[j] += at_res[j] - s_new[j];
149        }
150
151        // ── Convergence check ─────────────────────────────────────────────────
152        // Primal: how much x changed.
153        let x_change = {
154            let mut sq = 0.0_f64;
155            for j in 0..n {
156                let d = x_new[j] - x[j];
157                sq += d * d;
158            }
159            sq.sqrt()
160        };
161        // Constraint satisfaction: ||Aᵀ(b - Ax)||_∞ ≤ delta + tol?
162        let constraint_viol = at_res.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
163        let x_norm = norm2(&x_new).max(1.0e-300);
164
165        x = x_new;
166        s = s_new;
167
168        if (constraint_viol - cfg.delta).max(0.0) < cfg.tol && x_change / x_norm < cfg.tol {
169            break;
170        }
171    }
172
173    Ok(x)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // ── Helper ────────────────────────────────────────────────────────────────
181
182    /// Compute ||Aᵀ(b − Ax)||_∞ for checking the Dantzig constraint.
183    fn constraint_inf(a: &[f64], b: &[f64], x: &[f64], m: usize, n: usize) -> f64 {
184        let ax: Vec<f64> = (0..m)
185            .map(|i| (0..n).map(|j| a[i * n + j] * x[j]).sum())
186            .collect();
187        let resid: Vec<f64> = (0..m).map(|i| b[i] - ax[i]).collect();
188        let at_r: Vec<f64> = (0..n)
189            .map(|j| (0..m).map(|i| a[i * n + j] * resid[i]).sum())
190            .collect();
191        at_r.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
192    }
193
194    // ── Test 1: output length equals n ───────────────────────────────────────
195    #[test]
196    fn output_len() {
197        let a = vec![1.0, 0.0, 0.0, 1.0]; // 2×2
198        let b = vec![1.0, 0.5];
199        let cfg = DantzigConfig {
200            delta: 0.1,
201            max_iter: 50,
202            tol: 1.0e-6,
203        };
204        let x = dantzig_selector(&a, &b, 2, 2, &cfg).expect("output_len");
205        assert_eq!(x.len(), 2);
206    }
207
208    // ── Test 2: constraint approximately satisfied after convergence ──────────
209    #[test]
210    fn constraint_satisfied() {
211        let a = vec![
212            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
213        ];
214        let b = vec![1.0, 0.0, 0.5, 0.0];
215        let delta = 0.05_f64;
216        let cfg = DantzigConfig {
217            delta,
218            max_iter: 200,
219            tol: 1.0e-7,
220        };
221        let x = dantzig_selector(&a, &b, 4, 4, &cfg).expect("constraint_satisfied");
222        let viol = constraint_inf(&a, &b, &x, 4, 4);
223        // Allow generous slack: constraint should be within delta + 0.5 at least.
224        assert!(
225            viol < delta + 0.5,
226            "constraint violation {viol} exceeds delta + slack"
227        );
228    }
229
230    // ── Test 3: sparse solution for sparse-friendly problem ───────────────────
231    #[test]
232    fn sparse_solution() {
233        // 4×4 identity: only two measurements non-zero → at most 2 active columns.
234        let a = vec![
235            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
236        ];
237        let b = vec![1.0, 0.0, 0.5, 0.0];
238        let cfg = DantzigConfig {
239            delta: 0.01,
240            max_iter: 200,
241            tol: 1.0e-7,
242        };
243        let x = dantzig_selector(&a, &b, 4, 4, &cfg).expect("sparse_solution");
244        let nnz = x.iter().filter(|&&v| v.abs() > 1.0e-4).count();
245        // Expect at most 3 nonzeros (generous for ADMM; the signal has 2 active entries).
246        assert!(
247            nnz <= 3,
248            "expected sparse solution, got {nnz} nonzeros: {:?}",
249            x
250        );
251    }
252
253    // ── Test 4: 2×2 identity with small delta recovers approximately ──────────
254    #[test]
255    fn zero_noise_exact_recovery() {
256        let a = vec![1.0, 0.0, 0.0, 1.0]; // 2×2 identity
257        let b = vec![1.0, 0.5];
258        let cfg = DantzigConfig {
259            delta: 0.01,
260            max_iter: 200,
261            tol: 1.0e-8,
262        };
263        let x = dantzig_selector(&a, &b, 2, 2, &cfg).expect("zero_noise_exact_recovery");
264        assert!((x[0] - 1.0).abs() < 0.1, "x[0]={} not close to 1.0", x[0]);
265        assert!((x[1] - 0.5).abs() < 0.1, "x[1]={} not close to 0.5", x[1]);
266    }
267
268    // ── Test 5: very small delta should not panic ─────────────────────────────
269    #[test]
270    fn delta_too_small_ok() {
271        let a = vec![1.0, 0.0, 0.0, 1.0];
272        let b = vec![1.0, 0.5];
273        // delta = 1e-10 is extremely tight but must not panic.
274        let cfg = DantzigConfig {
275            delta: 1.0e-10,
276            max_iter: 30,
277            tol: 1.0e-5,
278        };
279        let result = dantzig_selector(&a, &b, 2, 2, &cfg);
280        assert!(
281            result.is_ok(),
282            "should not error on tiny delta: {:?}",
283            result
284        );
285        let x = result.expect("delta_too_small_ok");
286        assert!(
287            x.iter().all(|v| v.is_finite()),
288            "non-finite output: {:?}",
289            x
290        );
291    }
292
293    // ── Test 6: large tol stops early with few iterations ─────────────────────
294    #[test]
295    fn tol_stops_early() {
296        // We can't directly observe iteration count here, but we verify that a very
297        // large tol produces a valid (finite) result without running all max_iter steps.
298        let a = vec![
299            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
300        ];
301        let b = vec![1.0, 0.0, 0.5, 0.0];
302        // max_iter=1000 but tol=1e5 means it exits after the first iteration.
303        let cfg = DantzigConfig {
304            delta: 0.1,
305            max_iter: 1000,
306            tol: 1.0e5,
307        };
308        let x = dantzig_selector(&a, &b, 4, 4, &cfg).expect("tol_stops_early");
309        assert_eq!(x.len(), 4);
310        assert!(
311            x.iter().all(|v| v.is_finite()),
312            "non-finite output: {:?}",
313            x
314        );
315    }
316
317    // ── Test 7: all outputs are finite ────────────────────────────────────────
318    #[test]
319    fn output_finite() {
320        let a = vec![2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0];
321        let b = vec![1.0, 2.0, 1.5];
322        let cfg = DantzigConfig {
323            delta: 0.1,
324            max_iter: 100,
325            tol: 1.0e-6,
326        };
327        let x = dantzig_selector(&a, &b, 3, 3, &cfg).expect("output_finite");
328        assert!(
329            x.iter().all(|v| v.is_finite()),
330            "non-finite output: {:?}",
331            x
332        );
333    }
334
335    // ── Test 8: underdetermined (n > m) runs without error ────────────────────
336    #[test]
337    fn n_gt_m_underdetermined() {
338        // 3 × 6 measurement matrix (underdetermined).
339        let a = vec![
340            1.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0,
341            0.5,
342        ];
343        let b = vec![1.0, 0.5, 0.3];
344        let cfg = DantzigConfig {
345            delta: 0.1,
346            max_iter: 100,
347            tol: 1.0e-6,
348        };
349        let x = dantzig_selector(&a, &b, 3, 6, &cfg).expect("n_gt_m_underdetermined");
350        assert_eq!(x.len(), 6, "output length should be n=6");
351        assert!(
352            x.iter().all(|v| v.is_finite()),
353            "non-finite output: {:?}",
354            x
355        );
356    }
357
358    // ── Error path tests ─────────────────────────────────────────────────────
359
360    #[test]
361    fn empty_input_error_m0() {
362        let cfg = DantzigConfig {
363            delta: 0.1,
364            max_iter: 10,
365            tol: 1.0e-6,
366        };
367        let result = dantzig_selector(&[], &[], 0, 3, &cfg);
368        assert!(
369            matches!(result, Err(CsError::EmptyInput)),
370            "expected EmptyInput, got {:?}",
371            result
372        );
373    }
374
375    #[test]
376    fn empty_input_error_n0() {
377        let cfg = DantzigConfig {
378            delta: 0.1,
379            max_iter: 10,
380            tol: 1.0e-6,
381        };
382        let result = dantzig_selector(&[], &[1.0, 2.0], 2, 0, &cfg);
383        assert!(
384            matches!(result, Err(CsError::EmptyInput)),
385            "expected EmptyInput, got {:?}",
386            result
387        );
388    }
389
390    #[test]
391    fn invalid_delta_error() {
392        let a = vec![1.0, 0.0, 0.0, 1.0];
393        let b = vec![1.0, 0.5];
394        let cfg = DantzigConfig {
395            delta: -0.1,
396            max_iter: 10,
397            tol: 1.0e-6,
398        };
399        let result = dantzig_selector(&a, &b, 2, 2, &cfg);
400        assert!(
401            matches!(result, Err(CsError::InvalidParameter(_))),
402            "expected InvalidParameter, got {:?}",
403            result
404        );
405    }
406
407    #[test]
408    fn invalid_tol_error() {
409        let a = vec![1.0, 0.0, 0.0, 1.0];
410        let b = vec![1.0, 0.5];
411        let cfg = DantzigConfig {
412            delta: 0.1,
413            max_iter: 10,
414            tol: -1.0e-6,
415        };
416        let result = dantzig_selector(&a, &b, 2, 2, &cfg);
417        assert!(
418            matches!(result, Err(CsError::InvalidParameter(_))),
419            "expected InvalidParameter, got {:?}",
420            result
421        );
422    }
423}