Skip to main content

solver/linalg/
regression.rs

1use super::gaussian::solve_linear_system;
2use faer::prelude::*;
3
4/// Defines a common interface for solving Linear Least Squares problems: $\min_x ||Ax - b||^2$.
5///
6/// Implementations may vary by:
7/// 1. **Memory Usage**: Streaming (low memory) vs Full Matrix (high memory).
8/// 2. **Stability**: Normal Equations (unstable) vs QR/SVD (stable).
9pub trait RegressionSolver: Sized {
10    /// Creates a new solver instance initialized for `n_basis` features.
11    fn create(n_basis: usize) -> Self;
12
13    /// Adds a single observation (row of A, element of b) to the solver.
14    /// * `features`: The vector of basis function values $\phi(X)$ (row of design matrix).
15    /// * `target`: The target value $Y$.
16    fn add_observation(&mut self, features: &[f64], target: f64);
17
18    /// Solves the regression problem and returns the coefficients.
19    /// Consumes the solver state.
20    fn solve(self, lambda: f64) -> Vec<f64>;
21
22    /// Merges another solver state into this one (for parallel reduction).
23    fn merge(&mut self, other: Self);
24}
25
26/// Normal Equations Solver (Streaming)
27///
28/// Solves $(A^T A) x = A^T b$.
29/// * **Pros**: Low constant memory usage ($O(k^2)$ where $k$ is basis size). Parallelizable via summation.
30/// * **Cons**: Numerically unstable if condition number is high ($\kappa(A^T A) = \kappa(A)^2$).
31#[derive(Clone, Debug)]
32pub struct NormalEquationSolver {
33    n_basis: usize,
34    /// The matrix $A^T A$ (flattened, size $n \times n$).
35    ata: Vec<f64>,
36    /// The vector $A^T b$ (size $n$).
37    atb: Vec<f64>,
38}
39
40impl NormalEquationSolver {
41    pub fn new(n_basis: usize) -> Self {
42        Self::create(n_basis)
43    }
44}
45
46impl RegressionSolver for NormalEquationSolver {
47    fn create(n_basis: usize) -> Self {
48        Self {
49            n_basis,
50            ata: vec![0.0; n_basis * n_basis],
51            atb: vec![0.0; n_basis],
52        }
53    }
54
55    fn add_observation(&mut self, features: &[f64], target: f64) {
56        // Update A^T b: \sum \phi_i * y
57        for i in 0..self.n_basis {
58            // Using unsafe for performance in hot loop if bounds checks prove costly,
59            // but standard indexing is usually fine.
60            let feat_i = features[i];
61            self.atb[i] += feat_i * target;
62
63            // Update A^T A: \sum \phi_i * \phi_j
64            // Exploit symmetry: We only iterate j >= i
65            // We store row-major, so index is i * n_basis + j
66            let row_offset = i * self.n_basis;
67
68            // Diagonal element (j=i)
69            self.ata[row_offset + i] += feat_i * feat_i;
70
71            // Off-diagonal elements
72            for (j, &feat_j) in features.iter().enumerate().skip(i + 1) {
73                let val = feat_i * feat_j;
74                self.ata[row_offset + j] += val;
75                self.ata[j * self.n_basis + i] += val; // Symmetric update
76            }
77        }
78    }
79
80    fn solve(mut self, lambda: f64) -> Vec<f64> {
81        let n = self.n_basis;
82        let mut d = vec![1.0; n];
83        for (i, d_i) in d.iter_mut().enumerate() {
84            let diag = self.ata[i * n + i];
85            if diag > 1e-12 {
86                *d_i = 1.0 / diag.sqrt();
87            } else {
88                *d_i = 1.0; // avoid div by zero, feature is zero anyway
89            }
90        }
91
92        // Scale ata and atb
93        for i in 0..n {
94            for j in 0..n {
95                self.ata[i * n + j] *= d[i] * d[j];
96            }
97            self.atb[i] *= d[i];
98        }
99
100        // Tikhonov Regularization (Ridge Regression) proportional to unit scale
101        if lambda > 0.0 {
102            for i in 0..n {
103                self.ata[i * n + i] += lambda;
104            }
105        }
106
107        let x_tilde = solve_linear_system(&self.ata, &self.atb, n);
108
109        let mut x = vec![0.0; n];
110        for i in 0..n {
111            x[i] = x_tilde[i] * d[i];
112        }
113        x
114    }
115
116    fn merge(&mut self, other: Self) {
117        for i in 0..self.ata.len() {
118            self.ata[i] += other.ata[i];
119        }
120        for i in 0..self.atb.len() {
121            self.atb[i] += other.atb[i];
122        }
123    }
124}
125
126// Note: A FullMatrixSolver (QR/SVD) is difficult to implement purely based on this trait
127// with Rayon's `reduce` because merging two large matrices is expensive (O(N*M)).
128// The standard 'reduce' pattern favors the Streaming/NormalEquation approach.
129//
130// If rigorous stability is required (QR), we generally collect all data first
131// and then optimize, rather than streaming into a solver.
132
133/// Full Matrix Solver (Collects all data, then solves via QR Decomposition)
134///
135/// Solves $\min ||Ax - b||$.
136/// * **Pros**: Numerically stable. Correctly handles ill-conditioned matrices.
137/// * **Cons**: High memory usage ($O(N \times k)$ where $N$ is number of paths).
138#[derive(Clone, Debug)]
139pub struct FullMatrixSolver {
140    n_basis: usize,
141    /// The design matrix $A$ (flattened, row-major: rows=samples, cols=basis).
142    a: Vec<f64>,
143    /// The target vector $b$ (size=samples).
144    b: Vec<f64>,
145}
146
147impl FullMatrixSolver {
148    pub fn new(n_basis: usize) -> Self {
149        Self::create(n_basis)
150    }
151}
152
153impl RegressionSolver for FullMatrixSolver {
154    fn create(n_basis: usize) -> Self {
155        Self {
156            n_basis,
157            a: Vec::new(),
158            b: Vec::new(),
159        }
160    }
161
162    fn add_observation(&mut self, features: &[f64], target: f64) {
163        self.a.extend_from_slice(features);
164        self.b.push(target);
165    }
166
167    fn solve(self, _lambda: f64) -> Vec<f64> {
168        let n_samples = self.b.len();
169        let n_features = self.n_basis;
170
171        if n_samples < n_features {
172            return vec![0.0; n_features];
173        }
174
175        // Optimization: Convert to Column-Major layout for cache efficiency.
176        // MGS iterates column-by-column. Row-major storage means strided access (bad).
177        // Transpose A -> A_col (size: features x samples)
178        // This costs one allocation + copy, but speeds up the O(N*K^2) algorithm significantly.
179
180        let mut v_col = vec![0.0; n_samples * n_features];
181        // v_col[j * n_samples + i] corresponds to A[i, j]
182
183        for i in 0..n_samples {
184            for j in 0..n_features {
185                v_col[j * n_samples + i] = self.a[i * n_features + j];
186            }
187        }
188
189        // Q will also be stored column-major for consistent access
190        let mut q_col = vec![0.0; n_samples * n_features];
191        let mut r = vec![0.0; n_features * n_features]; // R is small, layout matters less
192
193        for i in 0..n_features {
194            let v_i_start = i * n_samples;
195            let v_i_end = v_i_start + n_samples;
196            let v_i = &v_col[v_i_start..v_i_end];
197
198            // 1. Compute Norm of current column v_i
199            // Linear scan!
200            let mut norm_sq = 0.0;
201            for &val in v_i.iter() {
202                norm_sq += val * val;
203            }
204            let norm = norm_sq.sqrt();
205            r[i * n_features + i] = norm;
206
207            // 2. Normalize to get q_i
208            let q_i_start = i * n_samples;
209            // q_col slice reference (mutable impossible while iterating, index manually)
210
211            if norm > 1e-12 {
212                let inv_norm = 1.0 / norm;
213                for k in 0..n_samples {
214                    q_col[q_i_start + k] = v_col[v_i_start + k] * inv_norm;
215                }
216            }
217
218            // 3. Orthogonalize remaining columns against q_i
219            for j in (i + 1)..n_features {
220                let v_j_start = j * n_samples;
221
222                // r_{ij} = <q_i, v_j>
223                let mut projection = 0.0;
224                let v_j = &v_col[v_j_start..(v_j_start + n_samples)];
225                let q_i = &q_col[q_i_start..(q_i_start + n_samples)];
226
227                // Vectorizable loop
228                for k in 0..n_samples {
229                    projection += q_i[k] * v_j[k];
230                }
231                r[i * n_features + j] = projection;
232
233                // v_j = v_j - r_{ij} * q_i
234                // Vectorizable loop
235                let v_j_mut = &mut v_col[v_j_start..(v_j_start + n_samples)];
236                for k in 0..n_samples {
237                    v_j_mut[k] -= projection * q_i[k];
238                }
239            }
240        }
241
242        // Solve Rx = Q^T b
243        // Q is column-major, so Q^T is effectively "row-major" if we view Q as tranposed?
244        // Actually, (Q^T b)_i = q_i \cdot b
245        // q_i is contiguous in q_col. So this is a dot product. Linear scan.
246
247        let mut y_rhs = vec![0.0; n_features];
248        for (i, y_rhs_i) in y_rhs.iter_mut().enumerate() {
249            let q_i_start = i * n_samples;
250            let mut sum = 0.0;
251            for (&a, &b) in q_col[q_i_start..].iter().zip(self.b.iter()) {
252                sum += a * b;
253            }
254            *y_rhs_i = sum;
255        }
256
257        // 2. Solve Rx = y by Back Substitution
258        let mut x = vec![0.0; n_features];
259        for i in (0..n_features).rev() {
260            let mut sum = y_rhs[i];
261            for j in (i + 1)..n_features {
262                sum -= r[i * n_features + j] * x[j];
263            }
264            if r[i * n_features + i].abs() > 1e-12 {
265                x[i] = sum / r[i * n_features + i];
266            } else {
267                x[i] = 0.0;
268            }
269        }
270
271        x
272    }
273
274    fn merge(&mut self, mut other: Self) {
275        self.a.append(&mut other.a);
276        self.b.append(&mut other.b);
277    }
278}
279
280/// Faer Solver (Pure Rust High-Performance)
281///
282/// Uses `faer` crate for QR decomposition.
283#[derive(Clone, Debug)]
284pub struct FaerSolver {
285    n_basis: usize,
286    a: Vec<f64>,
287    b: Vec<f64>,
288}
289
290impl RegressionSolver for FaerSolver {
291    fn create(n_basis: usize) -> Self {
292        Self {
293            n_basis,
294            a: Vec::new(),
295            b: Vec::new(),
296        }
297    }
298
299    fn add_observation(&mut self, features: &[f64], target: f64) {
300        self.a.extend_from_slice(features);
301        self.b.push(target);
302    }
303
304    fn solve(self, lambda: f64) -> Vec<f64> {
305        let n_samples = self.b.len();
306        let n_features = self.n_basis;
307        if n_samples < n_features {
308            return vec![0.0; n_features];
309        }
310
311        // faer generally prefers column-major matrices.
312        // 1. Construct Matrix A (col-major)
313        let mut a_col = vec![0.0; n_samples * n_features];
314        for i in 0..n_samples {
315            for j in 0..n_features {
316                a_col[j * n_samples + i] = self.a[i * n_features + j];
317            }
318        }
319
320        // Tikhonov regularization: augment A with sqrt(lambda)*I and b with zeros
321        // This transforms min||Ax-b||^2 + lambda*||x||^2 into min||[A; sqrt(lambda)*I]x - [b; 0]||^2
322        if lambda > 0.0 {
323            let sqrt_lam = lambda.sqrt();
324            let total_rows = n_samples + n_features;
325            let mut a_aug = vec![0.0; total_rows * n_features];
326            // Copy original A columns
327            for j in 0..n_features {
328                for i in 0..n_samples {
329                    a_aug[j * total_rows + i] = a_col[j * n_samples + i];
330                }
331                // Add sqrt(lambda) on diagonal of augmented part
332                a_aug[j * total_rows + n_samples + j] = sqrt_lam;
333            }
334            let mut b_aug = Vec::with_capacity(total_rows);
335            b_aug.extend_from_slice(&self.b);
336            b_aug.resize(total_rows, 0.0);
337
338            let a_mat = faer::mat::from_column_major_slice::<f64>(&a_aug, total_rows, n_features);
339            let b_mat = faer::mat::from_column_major_slice::<f64>(&b_aug, total_rows, 1);
340            let qr = a_mat.col_piv_qr();
341            let x = qr.solve_lstsq(b_mat);
342            return (0..n_features).map(|i| x.read(i, 0)).collect();
343        }
344
345        // No regularization path
346        let a_mat = faer::mat::from_column_major_slice::<f64>(&a_col, n_samples, n_features);
347        let b_mat = faer::mat::from_column_major_slice::<f64>(&self.b, n_samples, 1);
348        let qr = a_mat.col_piv_qr();
349        let x = qr.solve_lstsq(b_mat);
350        (0..n_features).map(|i| x.read(i, 0)).collect()
351    }
352
353    fn merge(&mut self, mut other: Self) {
354        self.a.append(&mut other.a);
355        self.b.append(&mut other.b);
356    }
357}