Skip to main content

zsfm_tabfm/ensemble/
nnls.rs

1//! Lawson-Hanson active-set NNLS (`scipy.optimize.nnls`'s algorithm): solves
2//! `min ||Ax - b||^2` subject to `x >= 0`. Self-contained (no external linear-algebra crate) —
3//! columns are few (`n_estimators <= 32`), so dense Gaussian elimination on the small
4//! passive-set normal-equations system is more than adequate.
5
6/// `a` is column-major: `a[j]` is the `j`-th column (length `m`, matching `b`'s length).
7/// Returns the `n = a.len()`-length non-negative solution.
8pub fn nnls(a: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
9    let n = a.len();
10    if n == 0 {
11        return vec![];
12    }
13    let m = b.len();
14    let mut x = vec![0.0f64; n];
15    let mut passive: Vec<usize> = Vec::new();
16    let mut active: Vec<usize> = (0..n).collect();
17
18    const TOL: f64 = 1e-10;
19    let max_iter = 3 * n + 10;
20
21    for _ in 0..max_iter {
22        // w = A^T (b - A x)
23        let residual: Vec<f64> = {
24            let ax = matvec(a, &x, m);
25            (0..m).map(|i| b[i] - ax[i]).collect()
26        };
27        let w: Vec<f64> = active.iter().map(|&j| dot(&a[j], &residual)).collect();
28
29        let Some((best_pos, &best_w)) = w.iter().enumerate().max_by(|a, b| a.1.total_cmp(&b.1))
30        else {
31            break;
32        };
33        if active.is_empty() || best_w <= TOL {
34            break;
35        }
36        let j = active.remove(best_pos);
37        passive.push(j);
38
39        loop {
40            let z_passive = solve_normal_equations(
41                &passive.iter().map(|&j| a[j].clone()).collect::<Vec<_>>(),
42                b,
43            );
44            if z_passive.iter().all(|&v| v > TOL) {
45                for (idx, &j) in passive.iter().enumerate() {
46                    x[j] = z_passive[idx];
47                }
48                break;
49            }
50            // Feasibility step: find alpha shrinking x toward z_passive without crossing 0.
51            let mut alpha = f64::INFINITY;
52            for (idx, &j) in passive.iter().enumerate() {
53                if z_passive[idx] <= TOL {
54                    let denom = x[j] - z_passive[idx];
55                    if denom > 0.0 {
56                        alpha = alpha.min(x[j] / denom);
57                    }
58                }
59            }
60            if !alpha.is_finite() {
61                alpha = 0.0;
62            }
63            for (idx, &j) in passive.iter().enumerate() {
64                x[j] += alpha * (z_passive[idx] - x[j]);
65            }
66            // Move near-zero passive indices back to active.
67            let mut still_passive = Vec::new();
68            for &j in &passive {
69                if x[j] <= TOL {
70                    x[j] = 0.0;
71                    active.push(j);
72                } else {
73                    still_passive.push(j);
74                }
75            }
76            passive = still_passive;
77            if passive.is_empty() {
78                break;
79            }
80        }
81    }
82    x
83}
84
85fn matvec(a: &[Vec<f64>], x: &[f64], m: usize) -> Vec<f64> {
86    let mut out = vec![0.0f64; m];
87    for (j, col) in a.iter().enumerate() {
88        if x[j] == 0.0 {
89            continue;
90        }
91        for i in 0..m {
92            out[i] += col[i] * x[j];
93        }
94    }
95    out
96}
97
98fn dot(a: &[f64], b: &[f64]) -> f64 {
99    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
100}
101
102/// Solves `argmin_z ||A_cols z - b||^2` (unconstrained) via the normal equations
103/// `(A^T A) z = A^T b`, Gaussian elimination with partial pivoting.
104fn solve_normal_equations(a_cols: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
105    let k = a_cols.len();
106    if k == 0 {
107        return vec![];
108    }
109    let mut ata = vec![vec![0.0f64; k]; k];
110    let mut atb = vec![0.0f64; k];
111    for i in 0..k {
112        atb[i] = dot(&a_cols[i], b);
113        for j in 0..k {
114            ata[i][j] = dot(&a_cols[i], &a_cols[j]);
115        }
116        ata[i][i] += 1e-10; // ridge for numerical stability on near-singular passive sets
117    }
118    gaussian_solve(&mut ata, &mut atb)
119}
120
121fn gaussian_solve(a: &mut [Vec<f64>], b: &mut [f64]) -> Vec<f64> {
122    let n = b.len();
123    for col in 0..n {
124        // Partial pivot.
125        let mut pivot_row = col;
126        let mut pivot_val = a[col][col].abs();
127        for row in (col + 1)..n {
128            if a[row][col].abs() > pivot_val {
129                pivot_val = a[row][col].abs();
130                pivot_row = row;
131            }
132        }
133        if pivot_val < 1e-14 {
134            continue; // singular column; leave corresponding solution as 0 later
135        }
136        a.swap(col, pivot_row);
137        b.swap(col, pivot_row);
138        let diag = a[col][col];
139        for row in (col + 1)..n {
140            let factor = a[row][col] / diag;
141            if factor == 0.0 {
142                continue;
143            }
144            for k in col..n {
145                a[row][k] -= factor * a[col][k];
146            }
147            b[row] -= factor * b[col];
148        }
149    }
150    let mut x = vec![0.0f64; n];
151    for i in (0..n).rev() {
152        if a[i][i].abs() < 1e-14 {
153            x[i] = 0.0;
154            continue;
155        }
156        let mut sum = b[i];
157        for j in (i + 1)..n {
158            sum -= a[i][j] * x[j];
159        }
160        x[i] = sum / a[i][i];
161    }
162    x
163}
164
165/// The wrapper's ensemble-weight post-processing: normalize NNLS weights to sum to 1 (fallback
166/// to uniform if the sum is 0), then blend with uniform via `nnls_beta`.
167pub fn finalize_weights(raw_weights: &[f64], nnls_beta: f64) -> Vec<f64> {
168    let n = raw_weights.len();
169    let sum: f64 = raw_weights.iter().sum();
170    let normalized: Vec<f64> = if sum > 0.0 {
171        raw_weights.iter().map(|&w| w / sum).collect()
172    } else {
173        vec![1.0 / n as f64; n]
174    };
175    let uniform = 1.0 / n as f64;
176    normalized
177        .iter()
178        .map(|&w| nnls_beta * w + (1.0 - nnls_beta) * uniform)
179        .collect()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_nnls_exact_solution() {
188        // A = [[1,0],[0,1],[1,1]], b = [1,2,3] -> exact solution x=[1,2]
189        let a = vec![vec![1.0, 0.0, 1.0], vec![0.0, 1.0, 1.0]];
190        let b = vec![1.0, 2.0, 3.0];
191        let x = nnls(&a, &b);
192        assert!((x[0] - 1.0).abs() < 1e-6, "x0={}", x[0]);
193        assert!((x[1] - 2.0).abs() < 1e-6, "x1={}", x[1]);
194    }
195
196    #[test]
197    fn test_nnls_enforces_nonnegativity() {
198        // A single column strongly anti-correlated with b should be driven to 0, not negative.
199        let a = vec![vec![-1.0, -1.0, -1.0]];
200        let b = vec![1.0, 1.0, 1.0];
201        let x = nnls(&a, &b);
202        assert!(x[0] >= 0.0);
203    }
204
205    #[test]
206    fn test_nnls_matches_scipy() {
207        // Generated via: rng = np.random.default_rng(3); A = rng.uniform(-1,1,(10,4));
208        // b = rng.uniform(0,1,10); scipy.optimize.nnls(A, b).
209        let a = vec![
210            vec![
211                -0.8287016657127513,
212                -0.8117427155192016,
213                0.46915430281842907,
214                -0.1387439591716444,
215                -0.4315976725024171,
216                -0.9970198329823277,
217                0.7834221408903144,
218                -0.9393079846750576,
219                0.3210001348557896,
220                -0.4036738186851505,
221            ],
222            vec![
223                -0.5263789868078006,
224                -0.1337461195270524,
225                -0.7726559601571932,
226                0.17359714287628147,
227                0.2970944141596501,
228                0.9469205495328255,
229                0.17032587978181613,
230                0.413930191311247,
231                0.862927709482709,
232                0.48351336013866075,
233            ],
234            vec![
235                0.6025489304127938,
236                -0.04189740371833195,
237                -0.21754361900867591,
238                0.4756755745843204,
239                0.39243199334031087,
240                -0.4031975539662487,
241                -0.057380669636337256,
242                -0.2515123330430584,
243                -0.5856176638379975,
244                0.44432961628423495,
245            ],
246            vec![
247                0.16432407212873557,
248                -0.6805221707258429,
249                0.03348036524272735,
250                0.9125345096721971,
251                -0.41455850197502575,
252                -0.3720279959313264,
253                0.5465540192976328,
254                -0.8182945729914843,
255                0.26018039957068595,
256                -0.5625691508623909,
257            ],
258        ];
259        let b = vec![
260            0.8298868742743123,
261            0.6576522108732432,
262            0.6827989078603502,
263            0.820075750170535,
264            0.42857290429846195,
265            0.758705461154919,
266            0.8784801846662539,
267            0.1023199219220744,
268            0.8497683374661538,
269            0.39392733263233515,
270        ];
271        let expected = [
272            0.0,
273            0.4483466391927005,
274            0.283691444258291,
275            0.19757999070784782,
276        ];
277        let x = nnls(&a, &b);
278        for (g, e) in x.iter().zip(expected.iter()) {
279            assert!((g - e).abs() < 1e-4, "got {g} vs expected {e}");
280        }
281    }
282
283    #[test]
284    fn test_finalize_weights_sums_to_one_when_beta_1() {
285        let w = finalize_weights(&[1.0, 3.0], 1.0);
286        assert!((w[0] + w[1] - 1.0).abs() < 1e-9);
287        assert!((w[0] - 0.25).abs() < 1e-9);
288    }
289
290    #[test]
291    fn test_finalize_weights_uniform_when_beta_0() {
292        let w = finalize_weights(&[1.0, 3.0], 0.0);
293        assert!((w[0] - 0.5).abs() < 1e-9);
294        assert!((w[1] - 0.5).abs() < 1e-9);
295    }
296}