Skip to main content

sklears_decomposition/dictionary_learning/
omp_algorithms.rs

1//! Orthogonal Matching Pursuit (OMP) algorithms for sparse coding
2
3use scirs2_core::ndarray::{Array1, Array2};
4#[cfg(feature = "serde")]
5use serde::{Deserialize, Serialize};
6use sklears_core::{error::Result, types::Float};
7
8/// Configuration for OMP algorithm
9#[derive(Debug, Clone)]
10#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
11pub struct OMPConfig {
12    /// Maximum number of non-zero coefficients
13    pub n_nonzero_coefs: Option<usize>,
14    /// Tolerance for residual norm
15    pub tol: Option<Float>,
16}
17
18impl Default for OMPConfig {
19    fn default() -> Self {
20        Self {
21            n_nonzero_coefs: None,
22            tol: Some(1e-4),
23        }
24    }
25}
26
27/// OMP algorithm result
28#[derive(Debug, Clone)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub struct OMPResult {
31    /// Sparse coefficients
32    pub coefficients: Array1<Float>,
33    /// Residual norm
34    pub residual_norm: Float,
35    /// Number of iterations
36    pub n_iter: usize,
37}
38
39/// Orthogonal Matching Pursuit encoder
40#[derive(Debug, Clone)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42pub struct OMPEncoder {
43    config: OMPConfig,
44}
45
46impl OMPEncoder {
47    pub fn new(config: OMPConfig) -> Self {
48        Self { config }
49    }
50
51    /// Perform OMP sparse coding
52    ///
53    /// Orthogonal Matching Pursuit is a greedy algorithm for sparse approximation.
54    /// It iteratively selects the dictionary atom most correlated with the residual,
55    /// then updates coefficients via least squares projection.
56    ///
57    /// # Arguments
58    /// * `dictionary` - Dictionary matrix where each column is an atom (n_features × n_atoms)
59    /// * `signal` - Signal to encode (n_features)
60    ///
61    /// # Returns
62    /// Sparse representation with coefficients, residual norm, and iteration count
63    pub fn encode(&self, dictionary: &Array2<Float>, signal: &Array1<Float>) -> Result<OMPResult> {
64        use sklears_core::error::SklearsError;
65
66        let (n_features, n_atoms) = dictionary.dim();
67
68        // Validate dimensions
69        if signal.len() != n_features {
70            return Err(SklearsError::InvalidInput(format!(
71                "Signal length {} doesn't match dictionary features {}",
72                signal.len(),
73                n_features
74            )));
75        }
76
77        // Normalize dictionary atoms (columns) for correlation computation
78        let mut dict_normalized = dictionary.clone();
79        for j in 0..n_atoms {
80            let col = dictionary.column(j);
81            let norm = col.mapv(|x| x * x).sum().sqrt();
82            if norm > 1e-10 {
83                for i in 0..n_features {
84                    dict_normalized[[i, j]] /= norm;
85                }
86            }
87        }
88
89        let mut coefficients = Array1::zeros(n_atoms);
90        let mut residual = signal.clone();
91        let mut selected_atoms: Vec<usize> = Vec::new();
92        let mut n_iter = 0;
93
94        // Determine maximum iterations
95        let max_iter = if let Some(k) = self.config.n_nonzero_coefs {
96            k.min(n_atoms)
97        } else {
98            n_atoms
99        };
100
101        let tol = self.config.tol.unwrap_or(1e-4);
102
103        // OMP iterations
104        for iteration in 0..max_iter {
105            // Compute residual norm
106            let residual_norm = residual.mapv(|x| x * x).sum().sqrt();
107
108            // Check stopping criterion (residual tolerance)
109            if residual_norm < tol {
110                n_iter = iteration;
111                break;
112            }
113
114            // Find atom with maximum absolute correlation with residual
115            let mut max_corr = 0.0;
116            let mut best_atom = 0;
117
118            for j in 0..n_atoms {
119                // Skip already selected atoms
120                if selected_atoms.contains(&j) {
121                    continue;
122                }
123
124                // Compute correlation: <dict[:,j], residual>
125                let mut corr = 0.0;
126                for i in 0..n_features {
127                    corr += dict_normalized[[i, j]] * residual[i];
128                }
129
130                let abs_corr = corr.abs();
131                if abs_corr > max_corr {
132                    max_corr = abs_corr;
133                    best_atom = j;
134                }
135            }
136
137            // Add best atom to selected set
138            selected_atoms.push(best_atom);
139
140            // Solve least squares on selected atoms:
141            // coeffs = (D_sel^T D_sel)^-1 D_sel^T signal
142            // Using normal equations for small problems
143            let k = selected_atoms.len();
144
145            // Build D_sel: n_features × k matrix
146            let mut d_sel = Array2::zeros((n_features, k));
147            for (col_idx, &atom_idx) in selected_atoms.iter().enumerate() {
148                for row_idx in 0..n_features {
149                    d_sel[[row_idx, col_idx]] = dictionary[[row_idx, atom_idx]];
150                }
151            }
152
153            // Compute Gram matrix: G = D_sel^T D_sel (k × k)
154            let mut gram = Array2::zeros((k, k));
155            for i in 0..k {
156                for j in 0..k {
157                    let mut sum = 0.0;
158                    for row in 0..n_features {
159                        sum += d_sel[[row, i]] * d_sel[[row, j]];
160                    }
161                    gram[[i, j]] = sum;
162                }
163            }
164
165            // Compute rhs: b = D_sel^T signal (k × 1)
166            let mut rhs = Array1::zeros(k);
167            for i in 0..k {
168                let mut sum = 0.0;
169                for row in 0..n_features {
170                    sum += d_sel[[row, i]] * signal[row];
171                }
172                rhs[i] = sum;
173            }
174
175            // Solve G @ x = rhs using Cholesky decomposition
176            // For small k, use simple Gaussian elimination
177            let selected_coeffs = solve_linear_system(&gram, &rhs)?;
178
179            // Update coefficients
180            coefficients.fill(0.0);
181            for (i, &atom_idx) in selected_atoms.iter().enumerate() {
182                coefficients[atom_idx] = selected_coeffs[i];
183            }
184
185            // Update residual: residual = signal - D_sel @ selected_coeffs
186            residual = signal.clone();
187            for (i, &atom_idx) in selected_atoms.iter().enumerate() {
188                for row in 0..n_features {
189                    residual[row] -= dictionary[[row, atom_idx]] * selected_coeffs[i];
190                }
191            }
192
193            n_iter = iteration + 1;
194
195            // Check if we've selected enough atoms
196            if let Some(k_max) = self.config.n_nonzero_coefs {
197                if selected_atoms.len() >= k_max {
198                    break;
199                }
200            }
201        }
202
203        let final_residual_norm = residual.mapv(|x| x * x).sum().sqrt();
204
205        Ok(OMPResult {
206            coefficients,
207            residual_norm: final_residual_norm,
208            n_iter,
209        })
210    }
211}
212
213/// Solve linear system Ax = b using Gaussian elimination with partial pivoting
214fn solve_linear_system(a: &Array2<Float>, b: &Array1<Float>) -> Result<Array1<Float>> {
215    use sklears_core::error::SklearsError;
216
217    let n = a.nrows();
218    if a.ncols() != n || b.len() != n {
219        return Err(SklearsError::InvalidInput(
220            "Matrix must be square and match RHS dimension".to_string(),
221        ));
222    }
223
224    // Create augmented matrix [A|b]
225    let mut aug = Array2::zeros((n, n + 1));
226    for i in 0..n {
227        for j in 0..n {
228            aug[[i, j]] = a[[i, j]];
229        }
230        aug[[i, n]] = b[i];
231    }
232
233    // Forward elimination with partial pivoting
234    for col in 0..n {
235        // Find pivot
236        let mut max_row = col;
237        let mut max_val = aug[[col, col]].abs();
238        for row in (col + 1)..n {
239            let val = aug[[row, col]].abs();
240            if val > max_val {
241                max_val = val;
242                max_row = row;
243            }
244        }
245
246        // Check for singularity
247        if max_val < 1e-10 {
248            return Err(SklearsError::NumericalError(
249                "Singular matrix in OMP least squares".to_string(),
250            ));
251        }
252
253        // Swap rows if needed
254        if max_row != col {
255            for j in 0..=n {
256                let temp = aug[[col, j]];
257                aug[[col, j]] = aug[[max_row, j]];
258                aug[[max_row, j]] = temp;
259            }
260        }
261
262        // Eliminate column
263        for row in (col + 1)..n {
264            let factor = aug[[row, col]] / aug[[col, col]];
265            for j in col..=n {
266                aug[[row, j]] -= factor * aug[[col, j]];
267            }
268        }
269    }
270
271    // Back substitution
272    let mut x = Array1::zeros(n);
273    for i in (0..n).rev() {
274        let mut sum = aug[[i, n]];
275        for j in (i + 1)..n {
276            sum -= aug[[i, j]] * x[j];
277        }
278        x[i] = sum / aug[[i, i]];
279    }
280
281    Ok(x)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use scirs2_core::ndarray::Array2;
288
289    #[test]
290    fn test_omp_simple_sparse_signal() {
291        // Create a simple 2-atom dictionary and sparse signal
292        // Dictionary: [[1, 0], [0, 1]] (identity)
293        // Signal: [3, 4]
294        // Expected: coefficients = [3, 4]
295        let dictionary = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, 0.0, 1.0])
296            .expect("shape and data length should match");
297        let signal = Array1::from_vec(vec![3.0, 4.0]);
298
299        let config = OMPConfig {
300            n_nonzero_coefs: Some(2),
301            tol: Some(1e-6),
302        };
303
304        let encoder = OMPEncoder::new(config);
305        let result = encoder
306            .encode(&dictionary, &signal)
307            .expect("serialization should succeed");
308
309        // Should find both atoms
310        assert!((result.coefficients[0] - 3.0).abs() < 1e-6);
311        assert!((result.coefficients[1] - 4.0).abs() < 1e-6);
312        assert!(result.residual_norm < 1e-6);
313    }
314
315    #[test]
316    fn test_omp_sparse_representation() {
317        // Create overcomplete dictionary (more atoms than features)
318        // Dictionary: 3 features, 5 atoms
319        let dictionary = Array2::from_shape_vec(
320            (3, 5),
321            vec![
322                // Atom 0   Atom 1   Atom 2   Atom 3   Atom 4
323                1.0, 0.5, 0.2, 0.8, 0.3, // Feature 0
324                0.0, 1.0, 0.3, 0.1, 0.7, // Feature 1
325                0.0, 0.0, 1.0, 0.2, 0.4, // Feature 2
326            ],
327        )
328        .expect("operation should succeed");
329
330        // Signal is a linear combination of atoms 0 and 2: signal = 2*atom0 + 3*atom2
331        let signal = Array1::from_vec(vec![
332            2.0 * 1.0 + 3.0 * 0.2, // 2.6
333            2.0 * 0.0 + 3.0 * 0.3, // 0.9
334            2.0 * 0.0 + 3.0 * 1.0, // 3.0
335        ]);
336
337        let config = OMPConfig {
338            n_nonzero_coefs: Some(2),
339            tol: Some(1e-4),
340        };
341
342        let encoder = OMPEncoder::new(config);
343        let result = encoder
344            .encode(&dictionary, &signal)
345            .expect("serialization should succeed");
346
347        // Should identify atoms 0 and 2
348        assert!(result.coefficients[0].abs() > 1.0); // Atom 0 selected
349        assert!(result.coefficients[2].abs() > 1.0); // Atom 2 selected
350        assert!(result.residual_norm < 0.1); // Low residual
351    }
352
353    #[test]
354    fn test_omp_tolerance_stopping() {
355        // Test that OMP stops when residual falls below tolerance
356        let dictionary =
357            Array2::from_shape_vec((3, 3), vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])
358                .expect("operation should succeed");
359        let signal = Array1::from_vec(vec![1.0, 2.0, 3.0]);
360
361        let config = OMPConfig {
362            n_nonzero_coefs: None, // No limit on atoms
363            tol: Some(1e-6),       // Very tight tolerance
364        };
365
366        let encoder = OMPEncoder::new(config);
367        let result = encoder
368            .encode(&dictionary, &signal)
369            .expect("serialization should succeed");
370
371        // Should stop when residual is small
372        assert!(result.residual_norm < 1e-5);
373        assert!(result.n_iter <= 3); // Should select all needed atoms
374    }
375
376    #[test]
377    fn test_omp_max_nonzero_coefs() {
378        // Test that OMP respects maximum non-zero coefficients
379        let dictionary = Array2::from_shape_vec(
380            (3, 5),
381            vec![
382                1.0, 0.5, 0.2, 0.8, 0.3, 0.0, 1.0, 0.3, 0.1, 0.7, 0.0, 0.0, 1.0, 0.2, 0.4,
383            ],
384        )
385        .expect("operation should succeed");
386        let signal = Array1::from_vec(vec![2.5, 1.5, 2.0]);
387
388        let config = OMPConfig {
389            n_nonzero_coefs: Some(2), // Limit to 2 atoms
390            tol: None,
391        };
392
393        let encoder = OMPEncoder::new(config);
394        let result = encoder
395            .encode(&dictionary, &signal)
396            .expect("serialization should succeed");
397
398        // Count non-zero coefficients
399        let nnz = result
400            .coefficients
401            .iter()
402            .filter(|&&x| x.abs() > 1e-10)
403            .count();
404        assert_eq!(nnz, 2);
405    }
406
407    #[test]
408    fn test_omp_exact_representation() {
409        // Test that OMP can exactly represent a signal in the span of dictionary
410        let dictionary = Array2::from_shape_vec(
411            (4, 3),
412            vec![1.0, 0.0, 0.5, 0.0, 1.0, 0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
413        )
414        .expect("operation should succeed");
415
416        // Signal = 2*atom0 + 3*atom1
417        let signal = Array1::from_vec(vec![2.0, 3.0, 0.0, 0.0]);
418
419        let config = OMPConfig {
420            n_nonzero_coefs: Some(3),
421            tol: Some(1e-6),
422        };
423
424        let encoder = OMPEncoder::new(config);
425        let result = encoder
426            .encode(&dictionary, &signal)
427            .expect("serialization should succeed");
428
429        // Reconstruct signal
430        let mut reconstructed = Array1::zeros(4);
431        for (atom_idx, &coef) in result.coefficients.iter().enumerate() {
432            for row in 0..4 {
433                reconstructed[row] += coef * dictionary[[row, atom_idx]];
434            }
435        }
436
437        // Check reconstruction error
438        let recon_error: Float = signal
439            .iter()
440            .zip(reconstructed.iter())
441            .map(|(a, b): (&Float, &Float)| (a - b).powi(2))
442            .sum::<Float>()
443            .sqrt();
444        assert!(recon_error < 1e-3);
445        assert!(result.residual_norm < 1e-3);
446    }
447
448    #[test]
449    fn test_omp_dimension_validation() {
450        // Test error handling for mismatched dimensions
451        let dictionary = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0])
452            .expect("shape and data length should match");
453        let signal = Array1::from_vec(vec![1.0, 2.0]); // Wrong size (should be 3)
454
455        let config = OMPConfig::default();
456        let encoder = OMPEncoder::new(config);
457        let result = encoder.encode(&dictionary, &signal);
458
459        assert!(result.is_err());
460    }
461
462    #[test]
463    fn test_solve_linear_system() {
464        // Test the linear system solver with a simple 2x2 system
465        // [2 1] [x1]   [5]
466        // [1 3] [x2] = [6]
467        // Solution: x1 = 9/5, x2 = 7/5
468        let a = Array2::from_shape_vec((2, 2), vec![2.0, 1.0, 1.0, 3.0])
469            .expect("shape and data length should match");
470        let b = Array1::from_vec(vec![5.0, 6.0]);
471
472        let x = solve_linear_system(&a, &b).expect("operation should succeed");
473
474        assert!((x[0] - 9.0 / 5.0).abs() < 1e-6);
475        assert!((x[1] - 7.0 / 5.0).abs() < 1e-6);
476    }
477
478    #[test]
479    fn test_solve_linear_system_identity() {
480        // Test with identity matrix
481        let a = Array2::from_shape_vec((3, 3), vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])
482            .expect("operation should succeed");
483        let b = Array1::from_vec(vec![4.0, 5.0, 6.0]);
484
485        let x = solve_linear_system(&a, &b).expect("operation should succeed");
486
487        assert!((x[0] - 4.0).abs() < 1e-10);
488        assert!((x[1] - 5.0).abs() < 1e-10);
489        assert!((x[2] - 6.0).abs() < 1e-10);
490    }
491
492    #[test]
493    fn test_solve_linear_system_with_pivoting() {
494        // Test system that requires pivoting
495        // [0 1] [x1]   [2]
496        // [1 1] [x2] = [3]
497        // Solution: x1 = 1, x2 = 2
498        let a = Array2::from_shape_vec((2, 2), vec![0.0, 1.0, 1.0, 1.0])
499            .expect("shape and data length should match");
500        let b = Array1::from_vec(vec![2.0, 3.0]);
501
502        let x = solve_linear_system(&a, &b).expect("operation should succeed");
503
504        assert!((x[0] - 1.0).abs() < 1e-6);
505        assert!((x[1] - 2.0).abs() < 1e-6);
506    }
507}