Skip to main content

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(&params.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}