1use ferrolearn_core::error::FerroError;
67use ferrolearn_core::traits::{Fit, Transform};
68use ndarray::Array2;
69use rand::SeedableRng;
70use rand_distr::{Distribution, Normal};
71use rand_xoshiro::Xoshiro256PlusPlus;
72
73fn reject_non_finite(x: &Array2<f64>) -> Result<(), FerroError> {
84 if x.iter().any(|v| !v.is_finite()) {
85 return Err(FerroError::InvalidParameter {
86 name: "X".into(),
87 reason: "Input X contains NaN or infinity.".into(),
88 });
89 }
90 Ok(())
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum DictFitAlgorithm {
100 CoordinateDescent,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum DictTransformAlgorithm {
107 Omp,
109 LassoCd,
111}
112
113#[derive(Debug, Clone)]
122pub struct DictionaryLearning {
123 n_components: usize,
125 alpha: f64,
127 max_iter: usize,
129 tol: f64,
131 fit_algorithm: DictFitAlgorithm,
133 transform_algorithm: DictTransformAlgorithm,
135 transform_n_nonzero_coefs: Option<usize>,
137 random_state: Option<u64>,
139}
140
141impl DictionaryLearning {
142 #[must_use]
147 pub fn new(n_components: usize) -> Self {
148 Self {
149 n_components,
150 alpha: 1.0,
151 max_iter: 1000,
152 tol: 1e-8,
153 fit_algorithm: DictFitAlgorithm::CoordinateDescent,
154 transform_algorithm: DictTransformAlgorithm::Omp,
155 transform_n_nonzero_coefs: None,
156 random_state: None,
157 }
158 }
159
160 #[must_use]
162 pub fn with_alpha(mut self, alpha: f64) -> Self {
163 self.alpha = alpha;
164 self
165 }
166
167 #[must_use]
169 pub fn with_max_iter(mut self, n: usize) -> Self {
170 self.max_iter = n;
171 self
172 }
173
174 #[must_use]
176 pub fn with_tol(mut self, tol: f64) -> Self {
177 self.tol = tol;
178 self
179 }
180
181 #[must_use]
183 pub fn with_fit_algorithm(mut self, algo: DictFitAlgorithm) -> Self {
184 self.fit_algorithm = algo;
185 self
186 }
187
188 #[must_use]
190 pub fn with_transform_algorithm(mut self, algo: DictTransformAlgorithm) -> Self {
191 self.transform_algorithm = algo;
192 self
193 }
194
195 #[must_use]
197 pub fn with_transform_n_nonzero_coefs(mut self, n: usize) -> Self {
198 self.transform_n_nonzero_coefs = Some(n);
199 self
200 }
201
202 #[must_use]
204 pub fn with_random_state(mut self, seed: u64) -> Self {
205 self.random_state = Some(seed);
206 self
207 }
208
209 #[must_use]
211 pub fn n_components(&self) -> usize {
212 self.n_components
213 }
214
215 #[must_use]
217 pub fn alpha(&self) -> f64 {
218 self.alpha
219 }
220
221 #[must_use]
223 pub fn max_iter(&self) -> usize {
224 self.max_iter
225 }
226
227 #[must_use]
229 pub fn tol(&self) -> f64 {
230 self.tol
231 }
232
233 #[must_use]
235 pub fn fit_algorithm(&self) -> DictFitAlgorithm {
236 self.fit_algorithm
237 }
238
239 #[must_use]
241 pub fn transform_algorithm(&self) -> DictTransformAlgorithm {
242 self.transform_algorithm
243 }
244
245 #[must_use]
247 pub fn random_state(&self) -> Option<u64> {
248 self.random_state
249 }
250}
251
252#[derive(Debug, Clone)]
262pub struct FittedDictionaryLearning {
263 components_: Array2<f64>,
266 alpha_: f64,
268 n_iter_: usize,
270 reconstruction_err_: f64,
272 transform_algorithm_: DictTransformAlgorithm,
274 transform_n_nonzero_coefs_: usize,
276}
277
278impl FittedDictionaryLearning {
279 #[must_use]
281 pub fn components(&self) -> &Array2<f64> {
282 &self.components_
283 }
284
285 #[must_use]
287 pub fn n_iter(&self) -> usize {
288 self.n_iter_
289 }
290
291 #[must_use]
293 pub fn reconstruction_err(&self) -> f64 {
294 self.reconstruction_err_
295 }
296}
297
298fn normalise_dictionary(d: &mut Array2<f64>) {
304 let n_components = d.nrows();
305 let n_features = d.ncols();
306 for k in 0..n_components {
307 let mut norm = 0.0;
308 for j in 0..n_features {
309 norm += d[[k, j]] * d[[k, j]];
310 }
311 let norm = norm.sqrt();
312 if norm > 1e-16 {
313 for j in 0..n_features {
314 d[[k, j]] /= norm;
315 }
316 }
317 }
318}
319
320fn lasso_cd_single(x_row: &[f64], d: &Array2<f64>, alpha: f64, max_iter: usize) -> Vec<f64> {
324 let n_components = d.nrows();
325 let n_features = d.ncols();
326 let mut a = vec![0.0; n_components];
327
328 let mut gram = vec![vec![0.0; n_components]; n_components];
330 let mut dx = vec![0.0; n_components];
331 for k in 0..n_components {
332 for j in 0..n_features {
333 dx[k] += d[[k, j]] * x_row[j];
334 }
335 for l in k..n_components {
336 let mut val = 0.0;
337 for j in 0..n_features {
338 val += d[[k, j]] * d[[l, j]];
339 }
340 gram[k][l] = val;
341 gram[l][k] = val;
342 }
343 }
344
345 for _iter in 0..max_iter {
346 let mut max_change = 0.0;
347 for k in 0..n_components {
348 let mut rho = dx[k];
350 for l in 0..n_components {
351 if l != k {
352 rho -= gram[k][l] * a[l];
353 }
354 }
355
356 let gram_kk = gram[k][k];
358 let new_a = if gram_kk.abs() < 1e-16 {
359 0.0
360 } else {
361 soft_threshold(rho, alpha) / gram_kk
362 };
363
364 let change = (new_a - a[k]).abs();
365 if change > max_change {
366 max_change = change;
367 }
368 a[k] = new_a;
369 }
370 if max_change < 1e-6 {
371 break;
372 }
373 }
374
375 a
376}
377
378fn omp_single(x_row: &[f64], d: &Array2<f64>, max_nonzero: usize) -> Vec<f64> {
380 let n_components = d.nrows();
381 let n_features = d.ncols();
382 let mut a = vec![0.0; n_components];
383 let mut residual: Vec<f64> = x_row.to_vec();
384 let mut selected: Vec<usize> = Vec::new();
385 let max_k = max_nonzero.min(n_components).min(n_features);
386
387 for _step in 0..max_k {
388 let mut best_idx = 0;
390 let mut best_corr = 0.0;
391 for k in 0..n_components {
392 if selected.contains(&k) {
393 continue;
394 }
395 let mut corr = 0.0;
396 for j in 0..n_features {
397 corr += d[[k, j]] * residual[j];
398 }
399 if corr.abs() > best_corr {
400 best_corr = corr.abs();
401 best_idx = k;
402 }
403 }
404
405 if best_corr < 1e-12 {
406 break;
407 }
408
409 selected.push(best_idx);
410
411 let m = selected.len();
414 let mut gram = vec![vec![0.0; m]; m];
415 let mut rhs = vec![0.0; m];
416 for (ii, &ki) in selected.iter().enumerate() {
417 for j in 0..n_features {
418 rhs[ii] += d[[ki, j]] * x_row[j];
419 }
420 for (jj, &kj) in selected.iter().enumerate() {
421 let mut val = 0.0;
422 for f in 0..n_features {
423 val += d[[ki, f]] * d[[kj, f]];
424 }
425 gram[ii][jj] = val;
426 }
427 }
428
429 if let Some(coefs) = solve_symmetric(&gram, &rhs) {
431 residual = x_row.to_vec();
433 for (ii, &ki) in selected.iter().enumerate() {
434 a[ki] = coefs[ii];
435 for j in 0..n_features {
436 residual[j] -= coefs[ii] * d[[ki, j]];
437 }
438 }
439 } else {
440 break;
441 }
442
443 let res_norm: f64 = residual.iter().map(|v| v * v).sum::<f64>().sqrt();
445 if res_norm < 1e-10 {
446 break;
447 }
448 }
449
450 a
451}
452
453#[allow(clippy::needless_range_loop)]
456fn solve_symmetric(a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
457 let n = b.len();
458 if n == 0 {
459 return Some(vec![]);
460 }
461
462 let mut aug: Vec<Vec<f64>> = Vec::with_capacity(n);
464 for (i, row) in a.iter().enumerate().take(n) {
465 let mut r = row.clone();
466 r.push(b[i]);
467 aug.push(r);
468 }
469
470 for col in 0..n {
472 let mut max_val = aug[col][col].abs();
474 let mut max_row = col;
475 for row in (col + 1)..n {
476 if aug[row][col].abs() > max_val {
477 max_val = aug[row][col].abs();
478 max_row = row;
479 }
480 }
481 if max_val < 1e-14 {
482 return None;
483 }
484 aug.swap(col, max_row);
485
486 let pivot = aug[col][col];
487 for row in (col + 1)..n {
488 let factor = aug[row][col] / pivot;
489 for j in col..=n {
490 let val = aug[col][j];
491 aug[row][j] -= factor * val;
492 }
493 }
494 }
495
496 let mut x = vec![0.0; n];
498 for i in (0..n).rev() {
499 let mut sum = aug[i][n];
500 for j in (i + 1)..n {
501 sum -= aug[i][j] * x[j];
502 }
503 x[i] = sum / aug[i][i];
504 }
505
506 Some(x)
507}
508
509fn soft_threshold(x: f64, lambda: f64) -> f64 {
511 if x > lambda {
512 x - lambda
513 } else if x < -lambda {
514 x + lambda
515 } else {
516 0.0
517 }
518}
519
520fn reconstruction_error(x: &Array2<f64>, a: &Array2<f64>, d: &Array2<f64>) -> f64 {
522 let ad = a.dot(d);
523 let mut err = 0.0;
524 for (xi, adi) in x.iter().zip(ad.iter()) {
525 let diff = xi - adi;
526 err += diff * diff;
527 }
528 err.sqrt()
529}
530
531impl Fit<Array2<f64>, ()> for DictionaryLearning {
536 type Fitted = FittedDictionaryLearning;
537 type Error = FerroError;
538
539 fn fit(&self, x: &Array2<f64>, _y: &()) -> Result<FittedDictionaryLearning, FerroError> {
548 let (n_samples, n_features) = x.dim();
549
550 if self.n_components == 0 {
552 return Err(FerroError::InvalidParameter {
553 name: "n_components".into(),
554 reason: "must be at least 1".into(),
555 });
556 }
557 if n_samples == 0 {
558 return Err(FerroError::InsufficientSamples {
559 required: 1,
560 actual: 0,
561 context: "DictionaryLearning::fit".into(),
562 });
563 }
564 if n_features == 0 {
565 return Err(FerroError::InvalidParameter {
566 name: "X".into(),
567 reason: "must have at least 1 feature".into(),
568 });
569 }
570 if self.alpha < 0.0 {
571 return Err(FerroError::InvalidParameter {
572 name: "alpha".into(),
573 reason: "must be non-negative".into(),
574 });
575 }
576 reject_non_finite(x)?;
580
581 let n_components = self.n_components;
582 let seed = self.random_state.unwrap_or(0);
583 let transform_n_nonzero = self.transform_n_nonzero_coefs.unwrap_or(n_components);
584
585 let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
587 let normal = Normal::new(0.0, 1.0).map_err(|e| FerroError::NumericalInstability {
588 message: format!(
589 "DictionaryLearning::fit: failed to construct Normal(0,1) for dictionary init: {e}"
590 ),
591 })?;
592 let mut d = Array2::<f64>::zeros((n_components, n_features));
593 for elem in &mut d {
594 *elem = normal.sample(&mut rng);
595 }
596 normalise_dictionary(&mut d);
597
598 let mut prev_err = f64::MAX;
599 let mut n_iter = 0;
600
601 for iteration in 0..self.max_iter {
602 n_iter = iteration + 1;
603
604 let mut a = Array2::<f64>::zeros((n_samples, n_components));
606 for i in 0..n_samples {
607 let x_row: Vec<f64> = (0..n_features).map(|j| x[[i, j]]).collect();
608 let codes = lasso_cd_single(&x_row, &d, self.alpha, 200);
609 for k in 0..n_components {
610 a[[i, k]] = codes[k];
611 }
612 }
613
614 let ata = a.t().dot(&a);
617 let atx = a.t().dot(x);
618
619 let gram: Vec<Vec<f64>> = (0..n_components)
622 .map(|i| (0..n_components).map(|j| ata[[i, j]]).collect())
623 .collect();
624
625 let mut gram_reg = gram.clone();
627 for (k, row) in gram_reg.iter_mut().enumerate() {
628 row[k] += 1e-10;
629 }
630
631 for j in 0..n_features {
632 let rhs: Vec<f64> = (0..n_components).map(|k| atx[[k, j]]).collect();
633 if let Some(sol) = solve_symmetric(&gram_reg, &rhs) {
634 for k in 0..n_components {
635 d[[k, j]] = sol[k];
636 }
637 }
638 }
639
640 normalise_dictionary(&mut d);
641
642 let err = reconstruction_error(x, &a, &d);
644 if (prev_err - err).abs() < self.tol {
645 break;
646 }
647 prev_err = err;
648 }
649
650 let mut a_final = Array2::<f64>::zeros((n_samples, n_components));
652 for i in 0..n_samples {
653 let x_row: Vec<f64> = (0..n_features).map(|j| x[[i, j]]).collect();
654 let codes = lasso_cd_single(&x_row, &d, self.alpha, 200);
655 for k in 0..n_components {
656 a_final[[i, k]] = codes[k];
657 }
658 }
659 let final_err = reconstruction_error(x, &a_final, &d);
660
661 Ok(FittedDictionaryLearning {
662 components_: d,
663 alpha_: self.alpha,
664 n_iter_: n_iter,
665 reconstruction_err_: final_err,
666 transform_algorithm_: self.transform_algorithm,
667 transform_n_nonzero_coefs_: transform_n_nonzero,
668 })
669 }
670}
671
672impl Transform<Array2<f64>> for FittedDictionaryLearning {
673 type Output = Array2<f64>;
674 type Error = FerroError;
675
676 fn transform(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
683 let n_features = self.components_.ncols();
684 if x.ncols() != n_features {
685 return Err(FerroError::ShapeMismatch {
686 expected: vec![x.nrows(), n_features],
687 actual: vec![x.nrows(), x.ncols()],
688 context: "FittedDictionaryLearning::transform".into(),
689 });
690 }
691
692 reject_non_finite(x)?;
696
697 let n_samples = x.nrows();
698 let n_components = self.components_.nrows();
699 let mut codes = Array2::<f64>::zeros((n_samples, n_components));
700
701 for i in 0..n_samples {
702 let x_row: Vec<f64> = (0..n_features).map(|j| x[[i, j]]).collect();
703 let a = match self.transform_algorithm_ {
704 DictTransformAlgorithm::Omp => {
705 omp_single(&x_row, &self.components_, self.transform_n_nonzero_coefs_)
706 }
707 DictTransformAlgorithm::LassoCd => {
708 lasso_cd_single(&x_row, &self.components_, self.alpha_, 200)
709 }
710 };
711 for k in 0..n_components {
712 codes[[i, k]] = a[k];
713 }
714 }
715
716 Ok(codes)
717 }
718}
719
720#[cfg(test)]
725mod tests {
726 use super::*;
727 use ndarray::Array2;
728
729 fn test_data() -> Array2<f64> {
731 Array2::<f64>::from_shape_fn((20, 10), |(i, j)| ((i * 7 + j * 3) % 11) as f64)
732 }
733
734 #[test]
735 fn test_dictlearn_basic_shape() {
736 let x = test_data();
737 let dl = DictionaryLearning::new(5)
738 .with_max_iter(20)
739 .with_random_state(42);
740 let fitted = dl.fit(&x, &()).unwrap();
741 assert_eq!(fitted.components().dim(), (5, 10));
742 }
743
744 #[test]
745 fn test_dictlearn_transform_shape() {
746 let x = test_data();
747 let dl = DictionaryLearning::new(5)
748 .with_max_iter(20)
749 .with_random_state(42);
750 let fitted = dl.fit(&x, &()).unwrap();
751 let codes = fitted.transform(&x).unwrap();
752 assert_eq!(codes.dim(), (20, 5));
753 }
754
755 #[test]
756 fn test_dictlearn_reconstruction_error_decreases() {
757 let x = test_data();
758 let dl_few = DictionaryLearning::new(5)
759 .with_max_iter(5)
760 .with_random_state(42);
761 let dl_many = DictionaryLearning::new(5)
762 .with_max_iter(50)
763 .with_random_state(42);
764 let fitted_few = dl_few.fit(&x, &()).unwrap();
765 let fitted_many = dl_many.fit(&x, &()).unwrap();
766 assert!(
767 fitted_many.reconstruction_err() <= fitted_few.reconstruction_err() + 1.0,
768 "more iterations should reduce error: few={}, many={}",
769 fitted_few.reconstruction_err(),
770 fitted_many.reconstruction_err()
771 );
772 }
773
774 #[test]
775 fn test_dictlearn_dictionary_atoms_normalised() {
776 let x = test_data();
777 let dl = DictionaryLearning::new(5)
778 .with_max_iter(20)
779 .with_random_state(42);
780 let fitted = dl.fit(&x, &()).unwrap();
781 let d = fitted.components();
782 for k in 0..d.nrows() {
783 let norm: f64 = d.row(k).iter().map(|v| v * v).sum::<f64>().sqrt();
784 assert!(
785 (norm - 1.0).abs() < 1e-6,
786 "atom {k} should be unit norm, got {norm}"
787 );
788 }
789 }
790
791 #[test]
792 fn test_dictlearn_sparsity_of_codes() {
793 let x = test_data();
794 let dl = DictionaryLearning::new(8)
795 .with_alpha(2.0) .with_max_iter(20)
797 .with_random_state(42);
798 let fitted = dl.fit(&x, &()).unwrap();
799 let codes = fitted.transform(&x).unwrap();
800 let total = codes.len();
802 let zeros = codes.iter().filter(|&&v| v.abs() < 1e-10).count();
803 let sparsity = zeros as f64 / total as f64;
804 assert!(
805 sparsity > 0.1,
806 "codes should have some sparsity, got {:.1}%",
807 sparsity * 100.0
808 );
809 }
810
811 #[test]
812 fn test_dictlearn_omp_transform() {
813 let x = test_data();
814 let dl = DictionaryLearning::new(5)
815 .with_max_iter(20)
816 .with_transform_algorithm(DictTransformAlgorithm::Omp)
817 .with_random_state(42);
818 let fitted = dl.fit(&x, &()).unwrap();
819 let codes = fitted.transform(&x).unwrap();
820 assert_eq!(codes.dim(), (20, 5));
821 }
822
823 #[test]
824 fn test_dictlearn_lasso_cd_transform() {
825 let x = test_data();
826 let dl = DictionaryLearning::new(5)
827 .with_max_iter(20)
828 .with_transform_algorithm(DictTransformAlgorithm::LassoCd)
829 .with_random_state(42);
830 let fitted = dl.fit(&x, &()).unwrap();
831 let codes = fitted.transform(&x).unwrap();
832 assert_eq!(codes.dim(), (20, 5));
833 }
834
835 #[test]
836 fn test_dictlearn_transform_shape_mismatch() {
837 let x = test_data();
838 let dl = DictionaryLearning::new(5)
839 .with_max_iter(10)
840 .with_random_state(42);
841 let fitted = dl.fit(&x, &()).unwrap();
842 let x_bad = Array2::<f64>::zeros((5, 3)); assert!(fitted.transform(&x_bad).is_err());
844 }
845
846 #[test]
847 fn test_dictlearn_invalid_n_components_zero() {
848 let x = test_data();
849 let dl = DictionaryLearning::new(0);
850 assert!(dl.fit(&x, &()).is_err());
851 }
852
853 #[test]
854 fn test_dictlearn_invalid_alpha_negative() {
855 let x = test_data();
856 let dl = DictionaryLearning::new(5).with_alpha(-1.0);
857 assert!(dl.fit(&x, &()).is_err());
858 }
859
860 #[test]
861 fn test_dictlearn_empty_data() {
862 let x = Array2::<f64>::zeros((0, 5));
863 let dl = DictionaryLearning::new(2);
864 assert!(dl.fit(&x, &()).is_err());
865 }
866
867 #[test]
868 fn test_dictlearn_zero_features() {
869 let x = Array2::<f64>::zeros((10, 0));
870 let dl = DictionaryLearning::new(2);
871 assert!(dl.fit(&x, &()).is_err());
872 }
873
874 #[test]
875 fn test_dictlearn_getters() {
876 let dl = DictionaryLearning::new(5)
877 .with_alpha(0.5)
878 .with_max_iter(100)
879 .with_tol(1e-6)
880 .with_fit_algorithm(DictFitAlgorithm::CoordinateDescent)
881 .with_transform_algorithm(DictTransformAlgorithm::LassoCd)
882 .with_random_state(99);
883 assert_eq!(dl.n_components(), 5);
884 assert!((dl.alpha() - 0.5).abs() < 1e-10);
885 assert_eq!(dl.max_iter(), 100);
886 assert!((dl.tol() - 1e-6).abs() < 1e-12);
887 assert_eq!(dl.fit_algorithm(), DictFitAlgorithm::CoordinateDescent);
888 assert_eq!(dl.transform_algorithm(), DictTransformAlgorithm::LassoCd);
889 assert_eq!(dl.random_state(), Some(99));
890 }
891
892 #[test]
893 fn test_dictlearn_fitted_accessors() {
894 let x = test_data();
895 let dl = DictionaryLearning::new(5)
896 .with_max_iter(10)
897 .with_random_state(42);
898 let fitted = dl.fit(&x, &()).unwrap();
899 assert!(fitted.n_iter() > 0);
900 assert!(fitted.reconstruction_err() >= 0.0);
901 }
902
903 #[test]
904 fn test_dictlearn_single_component() {
905 let x = test_data();
906 let dl = DictionaryLearning::new(1)
907 .with_max_iter(20)
908 .with_random_state(42);
909 let fitted = dl.fit(&x, &()).unwrap();
910 assert_eq!(fitted.components().nrows(), 1);
911 let codes = fitted.transform(&x).unwrap();
912 assert_eq!(codes.ncols(), 1);
913 }
914
915 #[test]
916 fn test_dictlearn_omp_nonzero_coefs() {
917 let x = test_data();
918 let dl = DictionaryLearning::new(5)
919 .with_max_iter(20)
920 .with_transform_algorithm(DictTransformAlgorithm::Omp)
921 .with_transform_n_nonzero_coefs(2)
922 .with_random_state(42);
923 let fitted = dl.fit(&x, &()).unwrap();
924 let codes = fitted.transform(&x).unwrap();
925 for i in 0..codes.nrows() {
927 let nnz = codes.row(i).iter().filter(|&&v| v.abs() > 1e-10).count();
928 assert!(nnz <= 2, "row {i} has {nnz} non-zeros, expected at most 2");
929 }
930 }
931
932 #[test]
933 fn test_soft_threshold() {
934 assert!((soft_threshold(5.0, 2.0) - 3.0).abs() < 1e-10);
935 assert!((soft_threshold(-5.0, 2.0) - (-3.0)).abs() < 1e-10);
936 assert!((soft_threshold(1.0, 2.0)).abs() < 1e-10);
937 assert!((soft_threshold(0.0, 2.0)).abs() < 1e-10);
938 }
939}