bixverse_methods/methods/dgrdl.rs
1//! Implementation of the dual graph regularised sparse dictionary learning
2//! based on the work of Pan, et al., Cell Syst, 2022
3
4use faer::{
5 ColRef, Mat, MatRef, Scale,
6 linalg::solvers::{PartialPivLu, Solve},
7};
8use rand::{Rng, SeedableRng, rngs::StdRng};
9use rayon::prelude::*;
10use rustc_hash::FxHashMap;
11use std::time::Instant;
12
13use crate::graph::graph_structures::{adjacency_to_laplacian, get_knn_graph_adj};
14use crate::prelude::*;
15use bixverse_core::core::base::cors_similarity::column_pairwise_cos;
16use bixverse_core::core::math::linear_algebra::sylvester_solver;
17
18////////////
19// Params //
20////////////
21
22/// Structure to store the dual graph regularised dictionary learning params
23#[derive(Debug, Clone, Default)]
24pub struct DgrdlParams<T> {
25 /// Sparsity constraint (max non-zero coefficients per signal)
26 pub sparsity: usize,
27 /// Dictionary size
28 pub dict_size: usize,
29 /// Sample context regularisation weight
30 pub alpha: T,
31 /// Feature effect regularisation weight
32 pub beta: T,
33 /// Maximum number of iterations
34 pub max_iter: usize,
35 /// Number of neighbours in the KNN graph
36 pub k_neighbours: usize,
37 /// ADMM iterations for sparse coding
38 pub admm_iter: usize,
39 /// ADMM step size
40 pub rho: T,
41}
42
43impl<T> DgrdlParams<T>
44where
45 T: BixverseFloat,
46{
47 /// Create default DGRDL parameters
48 ///
49 /// ### Returns
50 ///
51 /// `DgrdlParams` with default parameter configuration
52 #[allow(dead_code)]
53 fn default() -> Self {
54 Self {
55 sparsity: 5,
56 dict_size: 50,
57 alpha: T::from_f32(1.0).unwrap(),
58 beta: T::from_f32(1.0).unwrap(),
59 max_iter: 20,
60 k_neighbours: 5,
61 admm_iter: 5,
62 rho: T::from_f32(1.0).unwrap(),
63 }
64 }
65}
66
67/////////////
68// Results //
69/////////////
70
71/// DGRDL algorithm results
72#[derive(Debug)]
73pub struct DgrdlResults<T> {
74 /// Learned dictionary of size n x k
75 pub dictionary: Mat<T>,
76 /// Sparse coefficients of size k x m
77 pub coefficients: Mat<T>,
78 /// Sample context Laplacian n × n
79 pub sample_laplacian: Mat<T>,
80 /// Feature context laplacian of size m x m
81 pub feature_laplacian: Mat<T>,
82}
83
84/////////////////
85// DGRDL Cache //
86/////////////////
87
88/// DGRDL cache for hyperparameter tuning
89#[derive(Debug)]
90pub struct DgrdlCache<T> {
91 /// Hash of the underlying data
92 pub data_hash: u64,
93 /// HashMap of the feature and sample Laplacian matrix to avoid
94 /// recomputations
95 pub laplacian_cache: FxHashMap<usize, (Mat<T>, Mat<T>)>,
96 /// HashMap of the dictionary cashe with (dict_size, seed) as keys to avoid
97 /// recomputation
98 pub dictionary_cache: FxHashMap<(usize, usize), Mat<T>>,
99 /// The distance matrix of the data as a flat vector
100 pub distance_matrix: Option<Vec<T>>,
101}
102
103impl<T: BixverseFloat> DgrdlCache<T> {
104 /// Generate a new instance of the Cache
105 ///
106 /// ### Returns
107 ///
108 /// Returns self with empty fields
109 pub fn new() -> Self {
110 Self {
111 data_hash: 0,
112 laplacian_cache: FxHashMap::default(),
113 dictionary_cache: FxHashMap::default(),
114 distance_matrix: None,
115 }
116 }
117
118 /// Clear cache if data has changed
119 ///
120 /// Will clear the cash if the Hash changed
121 ///
122 /// ### Params
123 ///
124 /// * `data` The data for which to calculate the Hash
125 fn validate_data(&mut self, data: &MatRef<T>) {
126 let new_hash = self.compute_data_hash(data);
127 if new_hash != self.data_hash {
128 self.clear_cache();
129 self.data_hash = new_hash;
130 }
131 }
132
133 /// Compute the hash of the data. This will test just some elements
134 ///
135 /// ### Params
136 ///
137 /// * `data` - The data for the DGRDL algorithm
138 ///
139 /// ### Returns
140 ///
141 /// Hash usize
142 fn compute_data_hash(&self, data: &MatRef<T>) -> u64 {
143 let mut hash = data.nrows() as u64;
144 hash = hash.wrapping_mul(31).wrapping_add(data.ncols() as u64);
145 if data.nrows() > 0 && data.ncols() > 0 {
146 hash = hash
147 .wrapping_mul(31)
148 .wrapping_add(data[(0, 0)].to_f64().unwrap().to_bits());
149 let last_row = data.nrows() - 1;
150 let last_col = data.ncols() - 1;
151 hash = hash
152 .wrapping_mul(31)
153 .wrapping_add(data[(last_row, last_col)].to_f64().unwrap().to_bits());
154 }
155 hash
156 }
157
158 /// Clears the internal Cache
159 fn clear_cache(&mut self) {
160 self.laplacian_cache.clear();
161 self.dictionary_cache.clear();
162 self.distance_matrix = None;
163 }
164}
165
166/// Default implementation for DgrdlCache
167impl<T> Default for DgrdlCache<T>
168where
169 T: BixverseFloat,
170{
171 fn default() -> Self {
172 Self {
173 data_hash: 0,
174 laplacian_cache: FxHashMap::default(),
175 dictionary_cache: FxHashMap::default(),
176 distance_matrix: None,
177 }
178 }
179}
180
181///////////////////
182// DGRDL objects //
183///////////////////
184
185/// Calculate individual components of DGRDL objective function
186#[derive(Debug, Clone)]
187pub struct DgrdlObjectives<T> {
188 /// Calculates the approximation error in form of squared Frobenius norm
189 pub approximation_error: T,
190 /// Measures the similiarity of coefficients for the feature Laplacian
191 pub feature_laplacian_objective: T,
192 /// Measures the smoothness of the dictionary in respect to sample Laplacian
193 pub sample_laplacian_objective: T,
194 /// Random seed for reproducibility
195 pub seed: usize,
196 /// Number of neighbours
197 pub k_neighbours: usize,
198 /// The size of the dictionary
199 pub dict_size: usize,
200}
201
202impl<T: BixverseFloat> DgrdlObjectives<T> {
203 /// Calculate the different objectives and reconstruction error for a given DGRDL run
204 ///
205 /// ### Params
206 ///
207 /// * `dat` - Input matrix
208 /// * `res` - The `DgrdlResults` of that run
209 /// * `alpha` - Sample context regularisation weight
210 /// * `beta` - Feature context regularisation weight
211 ///
212 /// ### Returns
213 ///
214 /// The `DgrdlObjectives` object
215 pub fn calculate(
216 dat: &MatRef<T>,
217 res: &DgrdlResults<T>,
218 alpha: T,
219 beta: T,
220 seed: usize,
221 k_neighbours: usize,
222 dict_size: usize,
223 ) -> Self {
224 let approximation_error =
225 Self::calculate_approximation_error(dat, &res.dictionary, &res.coefficients);
226
227 let feature_laplacian_objective =
228 beta * Self::calculate_trace_xlx(&res.coefficients, &res.feature_laplacian);
229
230 let sample_laplacian_objective =
231 alpha * Self::calculate_trace_ddl(&res.dictionary, &res.sample_laplacian);
232
233 Self {
234 approximation_error,
235 feature_laplacian_objective,
236 sample_laplacian_objective,
237 seed,
238 k_neighbours,
239 dict_size,
240 }
241 }
242
243 /// Calculate the reconstruction error
244 ///
245 /// ### Params
246 ///
247 /// * `x` - The input matrix
248 /// * `dictionary` - The yielded dictionary matrix from the algorithm
249 /// * `coefficients` - The yielded coefficient matrix from the algorithm
250 ///
251 /// ### Returns
252 ///
253 /// The Frobenius norm squared. The lower, the better.
254 fn calculate_approximation_error(
255 x: &MatRef<T>,
256 dictionary: &Mat<T>,
257 coefficients: &Mat<T>,
258 ) -> T {
259 let reconstruction = dictionary * coefficients;
260 let residual = x.as_ref() - &reconstruction;
261 residual.norm_l2().powi(2)
262 }
263
264 /// Calculate gene Laplacian term: tr(X·L_f·X^T)
265 ///
266 /// ### Params
267 ///
268 /// * `coefficients` - The yielded coefficient matrix from the algorithm
269 /// * `feature_laplacian` - The Laplacian matrix of the features
270 ///
271 /// ### Returns
272 ///
273 /// The trace of that part of the equation. The lower, the better.
274 fn calculate_trace_xlx(coefficients: &Mat<T>, feature_laplacian: &Mat<T>) -> T {
275 let xl = coefficients * feature_laplacian;
276
277 let mut trace = T::zero();
278 for i in 0..coefficients.nrows() {
279 for j in 0..coefficients.ncols() {
280 trace += xl[(i, j)] * coefficients[(i, j)];
281 }
282 }
283 trace
284 }
285
286 /// Calculate cell Laplacian term: tr(D^T·D·L_s)
287 ///
288 /// ### Params
289 ///
290 /// * `dictionary` - The yielded dictionary matrix from the algorithm
291 /// * `sample_laplacian` - The Laplacian matrix of the samples
292 ///
293 /// ### Returns
294 ///
295 /// The trace of that part of equation. The lower, the better.
296 fn calculate_trace_ddl(dictionary: &Mat<T>, sample_laplacian: &Mat<T>) -> T {
297 let dt_l = dictionary.transpose() * sample_laplacian;
298
299 let mut trace = T::zero();
300 for i in 0..dictionary.ncols() {
301 for j in 0..dictionary.nrows() {
302 trace += dt_l[(i, j)] * dictionary[(j, i)];
303 }
304 }
305 trace
306 }
307}
308
309/////////////
310// Helpers //
311/////////////
312
313/////////////
314// Helpers //
315/////////////
316
317/// Retrieve distance from compressed upper-triangle storage
318///
319/// ### Params
320///
321/// * `distances` - Pre-computed distance array in compressed format
322/// * `i` - First matrix index
323/// * `j` - Second matrix index
324/// * `n` - Original matrix size
325///
326/// ### Returns
327///
328/// Distance value between the two values
329fn get_distance<T: BixverseFloat>(distances: &[T], i: usize, j: usize, n: usize) -> T {
330 if i == j {
331 return T::zero();
332 }
333
334 let (min_idx, max_idx) = if i < j { (i, j) } else { (j, i) };
335 let linear_idx = (min_idx * (2 * n - min_idx - 1)) / 2 + (max_idx - min_idx - 1);
336 distances[linear_idx]
337}
338
339/// Create the Laplacian matrix for the features or samples
340///
341/// ### Fields
342///
343/// * `data` - The original data matrix with rows = samples and columns =
344/// features
345/// * `k` - Number of neighbours for the KNN graph
346/// * `features` - If `true` generated the Laplacian for the features, otherwise
347/// for the samples
348///
349/// ### Returns
350///
351/// The Laplacian matrix L = D - A where D is the degree matrix and A is the
352/// adjacency matrix
353fn get_dgrdl_laplacian<T: BixverseFloat>(data: &MatRef<T>, k: usize, features: bool) -> Mat<T> {
354 let cosine_sim = if features {
355 column_pairwise_cos(data)
356 } else {
357 column_pairwise_cos(&data.transpose())
358 };
359
360 let knn_adjacency = get_knn_graph_adj(&cosine_sim.as_ref(), k);
361
362 adjacency_to_laplacian(&knn_adjacency.as_ref(), false)
363}
364
365/// Function to do the ADMM on the GRSC
366///
367/// Solves the optimization problem:
368///
369/// min_X ||Y - DX||²_F + β·tr(X·L_f·X^T) + λ||X||_0
370///
371/// subject to sparsity constraint using alternating direction method of
372/// multipliers (ADMM).
373///
374/// ### Params
375///
376/// * `dictionary` - Current dictionary matrix D
377/// * `data` - Input data matrix Y
378/// * `feature_laplacian` - Feature graph Laplacian L_f
379/// * `sparsity` - Maximum number of non-zero coefficients per column
380/// * `beta` - Graph regularization weight
381/// * `max_iter` - Maximum ADMM iterations
382/// * `rho` - ADMM penalty parameter
383///
384/// ### Returns
385///
386/// Sparse coefficient matrix X satisfying the sparsity constraint
387pub fn grsc_admm<T: BixverseFloat>(
388 dictionary: &Mat<T>,
389 data: &MatRef<T>,
390 feature_laplacian: &Mat<T>,
391 sparsity: usize,
392 beta: T,
393 max_iter: usize,
394 rho: T,
395) -> Mat<T> {
396 let k = dictionary.ncols();
397 let m = data.ncols();
398
399 let gram = dictionary.transpose() * dictionary;
400 let dt_y = dictionary.transpose() * data;
401
402 #[allow(unused_assignments)]
403 let mut x: Mat<T> = Mat::zeros(k, m);
404 let mut z: Mat<T> = Mat::zeros(k, m);
405 let mut u: Mat<T> = Mat::zeros(k, m);
406
407 for _ in 0..max_iter {
408 x = admm_solve_x(&gram, &dt_y, &z, &u, feature_laplacian, beta, rho);
409
410 z = sparse_projection(&(&x + &u), sparsity);
411
412 u = &u + &x - &z;
413 }
414
415 z
416}
417
418/// Solve the X-update step in ADMM
419///
420/// Solves the Sylvester equation: AX + XB = C
421/// where A = G + ρI, B = βL_f, C = D^T Y + ρ(Z - U)
422///
423/// ### Params
424///
425/// * `gram` - Gram matrix G = D^T D
426/// * `dt_y` - Data projection D^T Y
427/// * `z` - Current Z variable from ADMM
428/// * `u` - Current dual variable from ADMM
429/// * `feature_laplacian` - Feature Laplacian L_f
430/// * `beta` - Graph regularization weight
431/// * `rho` - ADMM penalty parameter
432///
433/// ### Returns
434///
435/// Updated X matrix solving the regularized least squares problem
436fn admm_solve_x<T: BixverseFloat>(
437 gram: &Mat<T>,
438 dt_y: &Mat<T>,
439 z: &Mat<T>,
440 u: &Mat<T>,
441 feature_laplacian: &Mat<T>,
442 beta: T,
443 rho: T,
444) -> Mat<T> {
445 let k = gram.nrows();
446
447 let identity_mat: Mat<T> = Mat::identity(k, k);
448 let a: Mat<T> = gram + &(Scale(rho) * identity_mat);
449 let b = Scale(beta) * feature_laplacian;
450 let c = dt_y + &(Scale(rho) * (z - u));
451
452 sylvester_solver(&a.as_ref(), &b.as_ref(), &c.as_ref())
453}
454
455/// Apply sparsity constraint via hard thresholding
456///
457/// For each column, keeps only the largest (in absolute value) 'sparsity'
458/// number of coefficients and sets the rest to zero.
459///
460/// ### Params
461///
462/// * `x` - Input coefficient matrix
463/// * `sparsity` - Number of non-zero coefficients to keep per column
464///
465/// ### Returns
466///
467/// Sparse matrix with at most 'sparsity' non-zero entries per column
468fn sparse_projection<T: BixverseFloat>(x: &Mat<T>, sparsity: usize) -> Mat<T> {
469 let (k, m) = x.shape();
470 let mut result = Mat::zeros(k, m);
471
472 result.par_col_iter_mut().enumerate().for_each_init(
473 Vec::new,
474 |indexed_vals: &mut Vec<(usize, T)>, (j, mut out)| {
475 indexed_vals.clear();
476 indexed_vals.extend(x.col(j).iter().enumerate().map(|(i, &val)| (i, val)));
477
478 let take_count = sparsity.min(k);
479 if take_count < indexed_vals.len() {
480 indexed_vals
481 .select_nth_unstable_by(take_count, |a, b| b.1.abs().total_cmp(&a.1.abs()));
482 }
483
484 for &(idx, val) in indexed_vals.iter().take(sparsity) {
485 out[idx] = val;
486 }
487 },
488 );
489
490 result
491}
492
493/// Compute Euclidean distance between two columns
494///
495/// ### Params
496///
497/// * `col_i` - First column vector
498/// * `col_j` - Second column vector
499///
500/// ### Returns
501///
502/// Euclidean distance between the two columns
503fn column_distance<T: BixverseFloat>(col_i: ColRef<T>, col_j: ColRef<T>) -> T {
504 col_i
505 .iter()
506 .zip(col_j.iter())
507 .map(|(&a, &b)| (a - b).powi(2))
508 .fold(T::zero(), |acc, x| acc + x)
509 .sqrt()
510}
511
512/// Update the entire dictionary
513///
514/// Updates all dictionary atoms sequentially using graph regularisation.
515/// Each atom is updated to minimise the reconstruction error while
516/// maintaining smoothness according to the sample graph structure.
517///
518/// ### Params
519///
520/// * `data` - Input data matrix Y
521/// * `coefficients` - Current coefficient matrix X
522/// * `sample_laplacian` - Sample graph Laplacian L_s
523/// * `alpha` - Sample regularization weight
524///
525/// ### Returns
526///
527/// Updated dictionary matrix with normalised columns
528fn update_dictionary<T: BixverseFloat>(
529 data: &MatRef<T>,
530 coefficients: &Mat<T>,
531 sample_laplacian: &Mat<T>,
532 alpha: T,
533) -> Mat<T> {
534 let (n_contexts, _) = data.shape();
535 let k = coefficients.nrows();
536 let reg_term = (Scale(alpha) * sample_laplacian).to_owned();
537
538 let atom_columns: Vec<Vec<T>> = (0..k)
539 .into_par_iter()
540 .map_init(
541 || {
542 (
543 Mat::<T>::zeros(n_contexts, n_contexts),
544 Mat::<T>::zeros(n_contexts, 1),
545 )
546 },
547 |(system_matrix, rhs), atom_idx| {
548 let x_j = coefficients.row(atom_idx);
549 let active_signals: Vec<(usize, T)> = x_j
550 .iter()
551 .enumerate()
552 .filter_map(|(signal_idx, &coeff)| {
553 if coeff.abs() > T::from_f64(1e-12).unwrap() {
554 Some((signal_idx, coeff))
555 } else {
556 None
557 }
558 })
559 .collect();
560 if active_signals.is_empty() {
561 return vec![T::zero(); n_contexts];
562 }
563 let rhs_col = rhs.col_as_slice_mut(0);
564 rhs_col.fill(T::zero());
565 for &(signal_idx, coeff) in &active_signals {
566 for (o, &s) in rhs_col.iter_mut().zip(data.col(signal_idx).iter()) {
567 *o += s * coeff;
568 }
569 }
570 let x_j_norm_sq = active_signals
571 .iter()
572 .map(|&(_, coeff)| coeff * coeff)
573 .fold(T::zero(), |acc, x| acc + x);
574 if x_j_norm_sq > T::from_f64(1e-12).unwrap() {
575 system_matrix.as_mut().copy_from(reg_term.as_ref());
576 for i in 0..n_contexts {
577 system_matrix[(i, i)] = x_j_norm_sq + reg_term[(i, i)];
578 }
579 let lu = PartialPivLu::new(system_matrix.as_ref());
580 let solution = lu.solve(&*rhs);
581 let norm = solution.norm_l2();
582 if norm > T::from_f64(1e-12).unwrap() {
583 (0..n_contexts).map(|i| solution[(i, 0)] / norm).collect()
584 } else {
585 vec![T::zero(); n_contexts]
586 }
587 } else {
588 vec![T::zero(); n_contexts]
589 }
590 },
591 )
592 .collect();
593 let mut dictionary = Mat::zeros(n_contexts, k);
594 for (atom_idx, atom_col) in atom_columns.iter().enumerate() {
595 for (i, &val) in atom_col.iter().enumerate() {
596 dictionary[(i, atom_idx)] = val;
597 }
598 }
599 dictionary
600}
601
602//////////////////////////
603// DGRDL implementation //
604//////////////////////////
605
606/// Main DGRDL implementation
607///
608/// Implements the Dual Graph Regularized Dictionary Learning algorithm which
609/// learns a sparse dictionary representation while preserving local geometric
610/// structure in both sample and feature spaces through graph regularization.
611///
612/// ### Fields
613///
614/// * `params` - Configuration parameters for the algorithm
615#[derive(Debug)]
616pub struct Dgrdl<T> {
617 params: DgrdlParams<T>,
618 cache: DgrdlCache<T>,
619}
620
621impl<T: BixverseFloat> Dgrdl<T> {
622 /// Create a new DGRDL instance
623 ///
624 /// ### Params
625 ///
626 /// * `params` - Configuration parameters for the algorithm
627 ///
628 /// ### Returns
629 ///
630 /// New DGRDL instance ready for training
631 pub fn new(params: DgrdlParams<T>) -> Self {
632 Self {
633 params,
634 cache: DgrdlCache::new(),
635 }
636 }
637
638 /// Run DGRDL algorithm on input data (with initial hyper parameters)
639 ///
640 /// Implements the main optimization loop alternating between sparse coding
641 /// and dictionary update steps. The algorithm minimizes:
642 /// ||Y - DX||²_F + α·tr(D^T·D·L_s) + β·tr(X·L_f·X^T) + sparsity constraint
643 /// where Y is data, D is dictionary, X is coefficients, L_s and L_f are
644 /// sample and feature Laplacians respectively.
645 ///
646 /// ### Params
647 ///
648 /// * `data` - Input data matrix of size n_samples × n_features
649 /// * `seed` - Seed for dictionary initialisation.
650 /// * `verbose` - Whether to print iteration progress
651 ///
652 /// ### Returns
653 ///
654 /// `DgrdlResults` containing the learned dictionary, coefficients, and Laplacians
655 pub fn fit(&mut self, data: &MatRef<T>, seed: usize, verbose: bool) -> DgrdlResults<T> {
656 self.fit_with_params(data, self.params.clone(), seed, verbose)
657 }
658
659 /// Run DGRDL algorithm on input data (with provided hyperparameters)
660 ///
661 /// Implements the main optimization loop alternating between sparse coding
662 /// and dictionary update steps. The algorithm minimizes:
663 /// ||Y - DX||²_F + α·tr(D^T·D·L_s) + β·tr(X·L_f·X^T) + sparsity constraint
664 /// where Y is data, D is dictionary, X is coefficients, L_s and L_f are
665 /// sample and feature Laplacians respectively.
666 ///
667 /// ### Params
668 ///
669 /// * `data` - Input data matrix of size n_samples × n_features
670 /// * `params` - The `DgrdlParams` containing the parameters you wish to use.
671 /// * `seed` - Seed for dictionary initialisation.
672 /// * `verbose` - Whether to print iteration progress
673 ///
674 /// ### Returns
675 ///
676 /// `DgrdlResults` containing the learned dictionary, coefficients, and
677 /// Laplacians
678 pub fn fit_with_params(
679 &mut self,
680 data: &MatRef<T>,
681 params: DgrdlParams<T>,
682 seed: usize,
683 verbose: bool,
684 ) -> DgrdlResults<T> {
685 let (n_samples, n_features) = data.shape();
686
687 if verbose {
688 println!(
689 "Starting DGRDL run with {:?} samples and {:?} features.",
690 n_samples, n_features
691 )
692 }
693
694 let start_total = Instant::now();
695
696 let start_dictionary_gen = Instant::now();
697 let mut dictionary = self.get_dictionary(data, params.dict_size, seed);
698 let end_dictionary_gen = start_dictionary_gen.elapsed();
699
700 if verbose {
701 println!(
702 "DGRDL dictionary obtained in {:.2?} (cached: {}).",
703 end_dictionary_gen,
704 self.cache
705 .dictionary_cache
706 .contains_key(&(self.params.dict_size, seed))
707 );
708 }
709
710 let start_laplacian_gen = Instant::now();
711 let (feature_laplacian, sample_laplacian) = self.get_laplacians(data, params.k_neighbours);
712 let end_laplacian_gen = start_laplacian_gen.elapsed();
713
714 if verbose {
715 println!(
716 "DGRDL graph laplacians obtained in {:.2?} (cached: {}).",
717 end_laplacian_gen,
718 self.cache
719 .laplacian_cache
720 .contains_key(¶ms.k_neighbours)
721 );
722 }
723
724 let mut coefficients: Mat<T> = Mat::zeros(params.dict_size, n_features);
725
726 for iter in 0..self.params.max_iter {
727 let start_iter = Instant::now();
728
729 coefficients = grsc_admm(
730 &dictionary,
731 data,
732 &feature_laplacian,
733 params.sparsity,
734 params.beta,
735 params.admm_iter,
736 params.rho,
737 );
738
739 dictionary = update_dictionary(data, &coefficients, &sample_laplacian, params.alpha);
740
741 let end_iter = start_iter.elapsed();
742
743 if verbose {
744 println!(
745 " DGRDL iteration {}/{} in {:.2?}.",
746 iter + 1,
747 params.max_iter,
748 end_iter
749 );
750 }
751 }
752
753 let end_time = start_total.elapsed();
754
755 if verbose {
756 println!(
757 "Total time elapsed for fitting the DGRDL run: {:.2?}.",
758 end_time
759 )
760 }
761
762 DgrdlResults {
763 dictionary,
764 coefficients,
765 sample_laplacian,
766 feature_laplacian,
767 }
768 }
769
770 /// Run a hyperparameter search over DGRDL
771 ///
772 /// Helper function to sweep over a set of hyperparameters and return
773 /// different objectives from the algorithm for subsequent visualisation.
774 ///
775 /// ### Params
776 ///
777 /// * `data` - Input data matrix of size n_samples × n_features
778 /// * `dict_sizes` - Slice of different dictionary sizes you wish to test
779 /// for.
780 /// * `k_neighbours_iters` - Slice of different k neighbours to iterate
781 /// over.
782 /// * `seeds` - Slice of random initialisations to test.
783 /// * `verbose` - Controls verbosity of the function
784 ///
785 /// ### Returns
786 ///
787 /// `DgrdlResults` containing the learned dictionary, coefficients, and
788 /// Laplacians
789 pub fn grid_search(
790 &mut self,
791 data: &MatRef<T>,
792 dict_sizes: &[usize],
793 k_neighbours_iters: &[usize],
794 seeds: &[usize],
795 verbose: bool,
796 ) -> Vec<DgrdlObjectives<T>> {
797 let (n_samples, n_features) = data.shape();
798
799 let n_dict = dict_sizes.len();
800 let n_k_neighbours = k_neighbours_iters.len();
801 let n_seeds = seeds.len();
802
803 let total_permutations = n_dict * n_k_neighbours * n_seeds;
804
805 if verbose {
806 println!(
807 "Starting DGRDL hyperparameter grid search with {:?} samples and {:?} features and {:?} permutations.",
808 n_samples, n_features, total_permutations
809 )
810 }
811
812 let start_total = Instant::now();
813 let mut iteration: usize = 1;
814
815 let mut grid_search_res: Vec<DgrdlObjectives<T>> = Vec::with_capacity(total_permutations);
816
817 for seed in seeds {
818 for dict_size in dict_sizes {
819 for &k_neighbours in k_neighbours_iters {
820 let iter_total = Instant::now();
821
822 let mut params = self.params.clone();
823 params.dict_size = *dict_size;
824 params.k_neighbours = k_neighbours;
825
826 if verbose {
827 println!(
828 " Iter ({}|{}) - seed = {} | dict_size = {} | k_neighbours = {}",
829 iteration, total_permutations, seed, dict_size, k_neighbours
830 );
831 }
832
833 let res = self.fit_with_params(data, params.clone(), *seed, false);
834
835 let metrics = DgrdlObjectives::calculate(
836 data,
837 &res,
838 params.alpha,
839 params.beta,
840 *seed,
841 k_neighbours,
842 *dict_size,
843 );
844
845 grid_search_res.push(metrics);
846
847 let iter_time = iter_total.elapsed();
848
849 if verbose {
850 println!(
851 " Iter ({}|{}) - finalised in {:.2?}",
852 iteration, total_permutations, iter_time
853 );
854 }
855
856 iteration += 1;
857 }
858 }
859 }
860
861 let total_time = start_total.elapsed();
862
863 if verbose {
864 println!("Finished hyperparameter grid search in {:.2?}", total_time);
865 }
866
867 grid_search_res
868 }
869
870 /// Get or compute dictionary for given dict_size and seed
871 ///
872 /// ### Params
873 ///
874 /// * `data` - Input data into the algorithm
875 /// * `dict_size` - The size of the dictionary
876 /// * `seed` - Random seed for the initialisation
877 ///
878 /// ### Returns
879 ///
880 /// The initial dictionary matrix
881 fn get_dictionary(&mut self, data: &MatRef<T>, dict_size: usize, seed: usize) -> Mat<T> {
882 self.cache.validate_data(data);
883
884 let key = (dict_size, seed);
885 if let Some(dictionary) = self.cache.dictionary_cache.get(&key) {
886 return dictionary.clone();
887 }
888
889 if self.cache.distance_matrix.is_none() {
890 self.cache.distance_matrix = Some(self.precompute_distances(data));
891 }
892
893 let dictionary = self.initialise_dictionary(data, dict_size, seed);
894
895 self.cache.dictionary_cache.insert(key, dictionary.clone());
896
897 dictionary
898 }
899
900 /// Initialise dictionary using k-medoids clustering
901 ///
902 /// Selects initial dictionary atoms by finding representative samples
903 /// (medoids) that minimise the total distance to all other samples.
904 /// This provides a good initialisation that captures data diversity. Will
905 /// leverage the pre-computed distances.
906 ///
907 /// ### Params
908 ///
909 /// * `data` - Input data matrix
910 /// * `dict_size` - The size of the dictionary.
911 /// * `seed` - Seed for the random initilisation of the first medoid.
912 ///
913 /// ### Returns
914 ///
915 /// Initialised dictionary matrix with normalized columns
916 fn initialise_dictionary(&self, data: &MatRef<T>, dict_size: usize, seed: usize) -> Mat<T> {
917 let mut rng = StdRng::seed_from_u64(seed as u64);
918
919 let k = dict_size;
920 let (n_samples, n_features) = data.shape();
921
922 let mut selected = Vec::with_capacity(k);
923
924 selected.push(rng.random_range(0..n_features));
925
926 let distances = self.cache.distance_matrix.as_ref().unwrap();
927
928 for _ in 1..k {
929 let min_distances: Vec<T> = (0..n_features)
930 .into_par_iter()
931 .map(|i| {
932 if selected.contains(&i) {
933 T::zero()
934 } else {
935 selected
936 .iter()
937 .map(|&j| get_distance(distances, i, j, n_features))
938 .fold(T::infinity(), |a, b| a.min(b))
939 }
940 })
941 .collect();
942
943 let total_weight = min_distances
944 .iter()
945 .copied()
946 .map(|d| d * d)
947 .fold(T::zero(), |acc, x| acc + x);
948
949 if total_weight > T::zero() {
950 let mut target = T::from_f64(rng.random::<f64>()).unwrap() * total_weight;
951 let mut chosen = 0;
952
953 for (i, &dist) in min_distances.iter().enumerate() {
954 target -= dist * dist;
955 if target <= T::zero() || i == n_features - 1 {
956 chosen = i;
957 break;
958 }
959 }
960
961 if !selected.contains(&chosen) {
962 selected.push(chosen);
963 }
964 } else {
965 let unselected: Vec<usize> =
966 (0..n_features).filter(|i| !selected.contains(i)).collect();
967 if !unselected.is_empty() {
968 selected.push(unselected[rng.random_range(0..unselected.len())]);
969 }
970 }
971 }
972
973 let mut dictionary = Mat::zeros(n_samples, k);
974 for (dict_idx, &feature_idx) in selected.iter().enumerate() {
975 let col = data.col(feature_idx);
976 let norm = col.norm_l2();
977 if norm > T::from_f64(1e-12).unwrap() {
978 for i in 0..n_samples {
979 dictionary[(i, dict_idx)] = col[i] / norm;
980 }
981 }
982 }
983 dictionary
984 }
985
986 /// Helper function to pre-calculate all the distances
987 ///
988 /// This function will calculate the cosine distance between all the
989 /// columns in a fast, sparse way
990 ///
991 /// ### Params
992 ///
993 /// * `data` - The input data
994 ///
995 /// ### Returns
996 ///
997 /// The flattened distances
998 fn precompute_distances(&self, data: &MatRef<T>) -> Vec<T> {
999 let n_features = data.ncols();
1000 let n_pairs = n_features * (n_features - 1) / 2;
1001
1002 let mut out = vec![T::zero(); n_pairs];
1003
1004 // row i of the upper triangle is one contiguous run of the flat layout
1005 let mut rows: Vec<(usize, &mut [T])> = Vec::with_capacity(n_features);
1006 let mut rest = out.as_mut_slice();
1007 for i in 0..n_features {
1008 let (row, tail) = rest.split_at_mut(n_features - i - 1);
1009 rows.push((i, row));
1010 rest = tail;
1011 }
1012
1013 rows.into_par_iter().for_each(|(i, row)| {
1014 for (slot, j) in row.iter_mut().zip(i + 1..) {
1015 *slot = column_distance(data.col(i), data.col(j));
1016 }
1017 });
1018
1019 out
1020 }
1021
1022 /// Pull out the Laplacians out of the cache or recompute for given k_neighbours
1023 ///
1024 /// ### Params
1025 ///
1026 /// * `data` - Input data
1027 /// * `k_neighbours` - Number of neighbours in the KNN graph
1028 ///
1029 /// ### Returns
1030 ///
1031 /// Tuple of `(feature_laplacian, sample_laplacian)`
1032 fn get_laplacians(&mut self, data: &MatRef<T>, k_neighbours: usize) -> (Mat<T>, Mat<T>) {
1033 self.cache.validate_data(data);
1034
1035 if let Some((feature_lap, sample_lap)) = self.cache.laplacian_cache.get(&k_neighbours) {
1036 return (feature_lap.clone(), sample_lap.clone());
1037 }
1038
1039 let feature_laplacian = get_dgrdl_laplacian(&data.as_ref(), k_neighbours, true);
1040 let sample_laplacian = get_dgrdl_laplacian(&data.as_ref(), k_neighbours, false);
1041
1042 self.cache.laplacian_cache.insert(
1043 k_neighbours,
1044 (feature_laplacian.clone(), sample_laplacian.clone()),
1045 );
1046
1047 (feature_laplacian, sample_laplacian)
1048 }
1049}