Skip to main content

sklears_decomposition/dictionary_learning/
dictionary_learning_core.rs

1//! Core Dictionary Learning Types and Configuration
2//!
3//! This module provides the fundamental dictionary learning types and configurations
4//! that comply with SciRS2 Policy for matrix decomposition algorithms.
5
6use scirs2_core::ndarray::Array2;
7use scirs2_core::random::thread_rng;
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10use sklears_core::{
11    error::{Result, SklearsError},
12    traits::{Estimator, Fit, Trained, Transform, Untrained},
13    types::Float,
14};
15
16/// Dictionary transformation algorithms
17#[derive(Debug, Clone, Copy)]
18#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19pub enum DictionaryTransformAlgorithm {
20    /// Orthogonal Matching Pursuit
21    OMP,
22    /// Least Angle Regression
23    LARS,
24    /// Coordinate Descent
25    CoordinateDescent,
26    /// Thresholding
27    Threshold,
28}
29
30/// Dictionary Learning configuration
31#[derive(Debug, Clone)]
32#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
33pub struct DictionaryLearningConfig {
34    /// Number of dictionary atoms
35    pub n_components: usize,
36    /// Maximum number of iterations
37    pub max_iter: usize,
38    /// Convergence tolerance
39    pub tol: Float,
40    /// Transform algorithm for sparse coding
41    pub transform_algorithm: DictionaryTransformAlgorithm,
42    /// Regularization parameter
43    pub alpha: Float,
44    /// Random state for reproducibility
45    pub random_state: Option<u64>,
46}
47
48impl Default for DictionaryLearningConfig {
49    fn default() -> Self {
50        Self {
51            n_components: 100,
52            max_iter: 1000,
53            tol: 1e-8,
54            transform_algorithm: DictionaryTransformAlgorithm::OMP,
55            alpha: 1.0,
56            random_state: None,
57        }
58    }
59}
60
61/// Dictionary Learning estimator
62#[derive(Debug, Clone)]
63#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
64pub struct DictionaryLearning<State = Untrained> {
65    config: DictionaryLearningConfig,
66    state: std::marker::PhantomData<State>,
67    /// Dictionary components (atoms) - only available when trained
68    components_: Option<Array2<Float>>,
69    /// Number of iterations performed - only available when trained
70    n_iter_: Option<usize>,
71}
72
73impl DictionaryLearning<Untrained> {
74    /// Create a new dictionary learning instance
75    pub fn new(config: DictionaryLearningConfig) -> Self {
76        Self {
77            config,
78            state: std::marker::PhantomData,
79            components_: None,
80            n_iter_: None,
81        }
82    }
83
84    /// Builder pattern constructor
85    pub fn builder() -> DictionaryLearningBuilder {
86        DictionaryLearningBuilder::default()
87    }
88}
89
90impl DictionaryLearning<Trained> {
91    /// Get the dictionary components
92    pub fn components(&self) -> &Array2<Float> {
93        self.components_.as_ref().expect("Model is trained")
94    }
95
96    /// Get the number of iterations performed
97    pub fn n_iter(&self) -> usize {
98        self.n_iter_.expect("Model is trained")
99    }
100}
101
102impl Estimator for DictionaryLearning<Untrained> {
103    type Config = DictionaryLearningConfig;
104    type Error = SklearsError;
105    type Float = Float;
106
107    fn config(&self) -> &Self::Config {
108        &self.config
109    }
110}
111
112impl Fit<Array2<Float>, ()> for DictionaryLearning<Untrained> {
113    type Fitted = DictionaryLearning<Trained>;
114
115    fn fit(self, x: &Array2<Float>, _y: &()) -> Result<Self::Fitted> {
116        let (_n_samples, n_features) = x.dim();
117
118        if self.config.n_components > n_features {
119            return Err(SklearsError::InvalidInput(
120                "n_components cannot be larger than n_features".to_string(),
121            ));
122        }
123
124        // Initialize dictionary randomly
125        let mut rng = thread_rng();
126        let mut components = Array2::zeros((self.config.n_components, n_features));
127
128        for i in 0..self.config.n_components {
129            for j in 0..n_features {
130                components[[i, j]] = rng.random::<Float>() - 0.5;
131            }
132        }
133
134        // Normalize initial dictionary atoms
135        for mut row in components.rows_mut() {
136            let norm = row.mapv(|x| x * x).sum().sqrt();
137            if norm > 1e-10 {
138                row.mapv_inplace(|x| x / norm);
139            }
140        }
141
142        // Dictionary learning via alternating optimization (MOD algorithm)
143        // Alternates between sparse coding and dictionary update
144        use super::omp_algorithms::{OMPConfig, OMPEncoder};
145
146        let n_samples = x.nrows();
147        let n_atoms = self.config.n_components;
148
149        // Configure OMP for sparse coding step
150        let omp_config = OMPConfig {
151            n_nonzero_coefs: Some((self.config.alpha * n_atoms as Float).max(1.0) as usize),
152            tol: Some(self.config.tol * 0.1), // Tighter tolerance for inner iterations
153        };
154
155        let mut n_iter = 0;
156        let mut prev_error = Float::INFINITY;
157
158        for iter in 0..self.config.max_iter {
159            n_iter = iter + 1;
160
161            // Step 1: Sparse coding - encode all samples with current dictionary
162            let mut codes = Array2::zeros((n_samples, n_atoms));
163            let encoder = OMPEncoder::new(omp_config.clone());
164            let dict_transposed = components.t().to_owned();
165
166            for i in 0..n_samples {
167                let signal = x.row(i).to_owned();
168                if let Ok(result) = encoder.encode(&dict_transposed, &signal) {
169                    for j in 0..n_atoms {
170                        codes[[i, j]] = result.coefficients[j];
171                    }
172                }
173            }
174
175            // Step 2: Dictionary update - minimize reconstruction error
176            // For each atom, update to minimize ||X - codes * Dictionary||_F^2
177            // Using simple least squares: each atom = X^T * codes_column / ||codes_column||^2
178
179            for atom_idx in 0..n_atoms {
180                // Compute residual without current atom
181                let mut residual = x.clone();
182                for i in 0..n_samples {
183                    for k in 0..n_atoms {
184                        if k != atom_idx {
185                            let coef = codes[[i, k]];
186                            for j in 0..n_features {
187                                residual[[i, j]] -= coef * components[[k, j]];
188                            }
189                        }
190                    }
191                }
192
193                // Update atom: atom = (residual^T * codes[:, atom_idx]) / ||codes[:, atom_idx]||^2
194                let codes_col_norm_sq: Float = (0..n_samples)
195                    .map(|i| codes[[i, atom_idx]] * codes[[i, atom_idx]])
196                    .sum();
197
198                if codes_col_norm_sq > 1e-10 {
199                    for j in 0..n_features {
200                        let mut numerator = 0.0;
201                        for i in 0..n_samples {
202                            numerator += residual[[i, j]] * codes[[i, atom_idx]];
203                        }
204                        components[[atom_idx, j]] = numerator / codes_col_norm_sq;
205                    }
206
207                    // Normalize the updated atom
208                    let atom_norm: Float = (0..n_features)
209                        .map(|j| components[[atom_idx, j]] * components[[atom_idx, j]])
210                        .sum::<Float>()
211                        .sqrt();
212
213                    if atom_norm > 1e-10 {
214                        for j in 0..n_features {
215                            components[[atom_idx, j]] /= atom_norm;
216                        }
217                    }
218                }
219            }
220
221            // Step 3: Check convergence using reconstruction error
222            let mut reconstruction_error = 0.0;
223            for i in 0..n_samples {
224                for j in 0..n_features {
225                    let mut pred = 0.0;
226                    for k in 0..n_atoms {
227                        pred += codes[[i, k]] * components[[k, j]];
228                    }
229                    let diff = x[[i, j]] - pred;
230                    reconstruction_error += diff * diff;
231                }
232            }
233            reconstruction_error =
234                (reconstruction_error / (n_samples * n_features) as Float).sqrt();
235
236            // Check for convergence
237            if (prev_error - reconstruction_error).abs() < self.config.tol {
238                break;
239            }
240            prev_error = reconstruction_error;
241        }
242
243        Ok(DictionaryLearning {
244            config: self.config,
245            state: std::marker::PhantomData,
246            components_: Some(components),
247            n_iter_: Some(n_iter),
248        })
249    }
250}
251
252impl Transform<Array2<Float>, Array2<Float>> for DictionaryLearning<Trained> {
253    fn transform(&self, x: &Array2<Float>) -> Result<Array2<Float>> {
254        use super::omp_algorithms::{OMPConfig, OMPEncoder};
255
256        let (n_samples, n_features) = x.dim();
257        let components = self.components();
258        let (n_atoms, dict_features) = components.dim();
259
260        // Validate dimensions
261        if n_features != dict_features {
262            return Err(SklearsError::InvalidInput(format!(
263                "Input features {} don't match dictionary features {}",
264                n_features, dict_features
265            )));
266        }
267
268        // Configure OMP encoder based on dictionary learning config
269        let omp_config = match self.config.transform_algorithm {
270            DictionaryTransformAlgorithm::OMP => OMPConfig {
271                n_nonzero_coefs: Some((self.config.alpha * n_atoms as Float) as usize),
272                tol: Some(self.config.tol),
273            },
274            _ => {
275                // For other algorithms, use OMP with default settings
276                OMPConfig {
277                    n_nonzero_coefs: None,
278                    tol: Some(self.config.tol),
279                }
280            }
281        };
282
283        let encoder = OMPEncoder::new(omp_config);
284        let mut codes = Array2::zeros((n_samples, n_atoms));
285
286        // Encode each sample using OMP
287        // Dictionary is transposed: components is (n_atoms × n_features)
288        // OMP expects dictionary as (n_features × n_atoms)
289        let dict_transposed = components.t().to_owned();
290
291        for i in 0..n_samples {
292            let signal = x.row(i).to_owned();
293            let result = encoder.encode(&dict_transposed, &signal)?;
294
295            // Copy coefficients to codes matrix
296            for j in 0..n_atoms {
297                codes[[i, j]] = result.coefficients[j];
298            }
299        }
300
301        Ok(codes)
302    }
303}
304
305/// Builder for Dictionary Learning
306#[derive(Debug, Clone, Default)]
307pub struct DictionaryLearningBuilder {
308    config: DictionaryLearningConfig,
309}
310
311impl DictionaryLearningBuilder {
312    pub fn n_components(mut self, n_components: usize) -> Self {
313        self.config.n_components = n_components;
314        self
315    }
316
317    pub fn max_iter(mut self, max_iter: usize) -> Self {
318        self.config.max_iter = max_iter;
319        self
320    }
321
322    pub fn tol(mut self, tol: Float) -> Self {
323        self.config.tol = tol;
324        self
325    }
326
327    pub fn transform_algorithm(mut self, algorithm: DictionaryTransformAlgorithm) -> Self {
328        self.config.transform_algorithm = algorithm;
329        self
330    }
331
332    pub fn alpha(mut self, alpha: Float) -> Self {
333        self.config.alpha = alpha;
334        self
335    }
336
337    pub fn random_state(mut self, random_state: Option<u64>) -> Self {
338        self.config.random_state = random_state;
339        self
340    }
341
342    pub fn build(self) -> DictionaryLearning<Untrained> {
343        DictionaryLearning::new(self.config)
344    }
345}
346
347// Type alias for backward compatibility
348pub type TrainedDictionaryLearning = DictionaryLearning<Trained>;
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_dictionary_learning_transform() {
356        // Create a simple dataset
357        let x = Array2::from_shape_vec(
358            (10, 5),
359            vec![
360                1.0, 2.0, 3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 3.0, 4.0, 5.0, 6.0, 7.0, 1.5,
361                2.5, 3.5, 4.5, 5.5, 2.5, 3.5, 4.5, 5.5, 6.5, 1.2, 2.2, 3.2, 4.2, 5.2, 2.2, 3.2,
362                4.2, 5.2, 6.2, 1.8, 2.8, 3.8, 4.8, 5.8, 2.8, 3.8, 4.8, 5.8, 6.8, 1.1, 2.1, 3.1,
363                4.1, 5.1,
364            ],
365        )
366        .expect("operation should succeed");
367
368        // Create and fit dictionary learning model
369        let config = DictionaryLearningConfig {
370            n_components: 3,
371            max_iter: 50,
372            tol: 1e-4,
373            transform_algorithm: DictionaryTransformAlgorithm::OMP,
374            alpha: 0.5,
375            random_state: Some(42),
376        };
377
378        let model = DictionaryLearning::new(config);
379        let fitted_model = model.fit(&x, &()).expect("model fitting should succeed");
380
381        // Test transform
382        let codes = fitted_model
383            .transform(&x)
384            .expect("transformation should succeed");
385
386        // Check dimensions
387        assert_eq!(codes.nrows(), 10); // n_samples
388        assert_eq!(codes.ncols(), 3); // n_components
389
390        // Check that codes are not all zeros (indicating actual encoding happened)
391        let sum: Float = codes.iter().map(|&x| x.abs()).sum();
392        assert!(sum > 0.0, "Codes should not be all zeros");
393    }
394
395    #[test]
396    fn test_dictionary_learning_dimension_validation() {
397        // Create a simple dataset
398        let x_train = Array2::from_shape_vec((5, 3), vec![1.0; 15])
399            .expect("shape and data length should match");
400        let x_test = Array2::from_shape_vec((3, 4), vec![1.0; 12])
401            .expect("shape and data length should match");
402
403        let config = DictionaryLearningConfig {
404            n_components: 2, // Less than n_features (3)
405            ..Default::default()
406        };
407        let model = DictionaryLearning::new(config);
408        let fitted_model = model
409            .fit(&x_train, &())
410            .expect("model fitting should succeed");
411
412        // Should fail due to dimension mismatch (x_test has 4 features, but model expects 3)
413        let result = fitted_model.transform(&x_test);
414        assert!(result.is_err());
415    }
416
417    #[test]
418    fn test_dictionary_learning_builder() {
419        let model = DictionaryLearning::builder()
420            .n_components(5)
421            .max_iter(100)
422            .tol(1e-5)
423            .alpha(0.3)
424            .random_state(Some(123))
425            .build();
426
427        assert_eq!(model.config.n_components, 5);
428        assert_eq!(model.config.max_iter, 100);
429        assert_eq!(model.config.tol, 1e-5);
430        assert_eq!(model.config.alpha, 0.3);
431        assert_eq!(model.config.random_state, Some(123));
432    }
433
434    #[test]
435    fn test_dictionary_learning_convergence() {
436        // Create a dataset with clear structure
437        let x = Array2::from_shape_vec(
438            (20, 8),
439            vec![
440                // Pattern 1: high values in first half
441                5.0, 5.0, 5.0, 5.0, 1.0, 1.0, 1.0, 1.0, 5.5, 5.5, 5.5, 5.5, 0.5, 0.5, 0.5, 0.5, 4.5,
442                4.5, 4.5, 4.5, 1.5, 1.5, 1.5, 1.5, 5.2, 5.2, 5.2, 5.2, 0.8, 0.8, 0.8, 0.8, 4.8,
443                4.8, 4.8, 4.8, 1.2, 1.2, 1.2, 1.2,
444                // Pattern 2: high values in second half
445                1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 0.5, 0.5, 0.5, 0.5, 5.5, 5.5, 5.5, 5.5, 1.5,
446                1.5, 1.5, 1.5, 4.5, 4.5, 4.5, 4.5, 0.8, 0.8, 0.8, 0.8, 5.2, 5.2, 5.2, 5.2, 1.2,
447                1.2, 1.2, 1.2, 4.8, 4.8, 4.8, 4.8, // Pattern 3: alternating
448                5.0, 1.0, 5.0, 1.0, 5.0, 1.0, 5.0, 1.0, 5.5, 0.5, 5.5, 0.5, 5.5, 0.5, 5.5, 0.5,
449                4.5, 1.5, 4.5, 1.5, 4.5, 1.5, 4.5, 1.5, 5.2, 0.8, 5.2, 0.8, 5.2, 0.8, 5.2, 0.8,
450                4.8, 1.2, 4.8, 1.2, 4.8, 1.2, 4.8, 1.2, // Mixed patterns
451                3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.5, 2.5, 3.5, 2.5, 3.5, 2.5, 3.5, 2.5,
452                2.5, 3.5, 2.5, 3.5, 2.5, 3.5, 2.5, 3.5, 4.0, 2.0, 3.0, 3.0, 2.0, 4.0, 3.0, 3.0,
453                2.0, 4.0, 3.0, 3.0, 4.0, 2.0, 3.0, 3.0,
454            ],
455        )
456        .expect("operation should succeed");
457
458        // Configure dictionary learning with reasonable parameters
459        let config = DictionaryLearningConfig {
460            n_components: 4,
461            max_iter: 30,
462            tol: 1e-3,
463            transform_algorithm: DictionaryTransformAlgorithm::OMP,
464            alpha: 0.3,
465            random_state: Some(42),
466        };
467
468        let model = DictionaryLearning::new(config);
469        let fitted_model = model.fit(&x, &()).expect("model fitting should succeed");
470
471        // Check that dictionary was learned
472        let components = fitted_model.components();
473        assert_eq!(components.nrows(), 4);
474        assert_eq!(components.ncols(), 8);
475
476        // Check that dictionary atoms are normalized
477        for atom_idx in 0..4 {
478            let norm: Float = (0..8)
479                .map(|j| components[[atom_idx, j]] * components[[atom_idx, j]])
480                .sum::<Float>()
481                .sqrt();
482            assert!(
483                (norm - 1.0).abs() < 1e-6,
484                "Atom {} not normalized: {}",
485                atom_idx,
486                norm
487            );
488        }
489
490        // Check that transform produces reasonable sparse codes
491        let codes = fitted_model
492            .transform(&x)
493            .expect("transformation should succeed");
494        assert_eq!(codes.nrows(), 20);
495        assert_eq!(codes.ncols(), 4);
496
497        // Verify sparsity of codes (most entries should be zero or very small)
498        let mut nonzero_count = 0;
499        for i in 0..codes.nrows() {
500            for j in 0..codes.ncols() {
501                if codes[[i, j]].abs() > 1e-10 {
502                    nonzero_count += 1;
503                }
504            }
505        }
506        let sparsity_ratio = nonzero_count as f64 / (codes.nrows() * codes.ncols()) as f64;
507        assert!(
508            sparsity_ratio < 0.5,
509            "Codes should be sparse, but {}% are nonzero",
510            sparsity_ratio * 100.0
511        );
512
513        // Verify reconstruction is reasonable (not perfect, but better than random)
514        let mut reconstruction_error = 0.0;
515        for i in 0..x.nrows() {
516            for j in 0..x.ncols() {
517                let mut reconstruction = 0.0;
518                for k in 0..components.nrows() {
519                    reconstruction += codes[[i, k]] * components[[k, j]];
520                }
521                let diff = x[[i, j]] - reconstruction;
522                reconstruction_error += diff * diff;
523            }
524        }
525        reconstruction_error = (reconstruction_error / (x.nrows() * x.ncols()) as Float).sqrt();
526
527        // Error should be reasonable (not too large)
528        assert!(
529            reconstruction_error < 3.0,
530            "Reconstruction error too high: {}",
531            reconstruction_error
532        );
533    }
534}