Skip to main content

bixverse_methods/methods/
snf.rs

1//! Similarity network fusion implementation based on Wang, et al., Nat Methods,
2//! 2014
3
4use ann_search_rs::prelude::SimdDistance;
5use faer::{Mat, MatRef};
6use rayon::prelude::*;
7use std::collections::BinaryHeap;
8
9use crate::prelude::*;
10use bixverse_core::core::base::cors_similarity::*;
11use bixverse_core::core::math::matrix_helpers::*;
12
13/////////////
14// Helpers //
15/////////////
16
17/// Create an affinity matrix from a distance matrix
18///
19/// Applies a scaled exponential similarity kernel based on K-nearest neighbors.
20/// The kernel uses an adaptive sigma parameter calculated from the average
21/// distance to K nearest neighbors. Sounds fancy, doesn't it... ?
22///
23/// ### Params
24///
25/// * `dist` - The distance matrix.
26/// * `k` - Number of neighbours to consider
27/// * `mu` - Controls the Gaussian kernel strength, i.e., sigma parameter
28///
29/// ### Returns
30///
31/// The resulting affinity matrix.
32fn affinity_from_distance<T>(dist: &MatRef<T>, k: usize, mu: T) -> Mat<T>
33where
34    T: BixverseFloat + std::iter::Sum,
35{
36    let n = dist.nrows();
37
38    // the distance matrix is symmetric, so column i is row i, contiguous
39    let knn_avg_dist: Vec<T> = (0..n)
40        .into_par_iter()
41        .map_init(Vec::new, |distances: &mut Vec<T>, i| {
42            distances.clear();
43            distances.extend(
44                dist.col(i)
45                    .iter()
46                    .enumerate()
47                    .filter(|&(j, _)| j != i)
48                    .map(|(_, &d)| d),
49            );
50
51            let k_actual = k.min(distances.len());
52            if k_actual < distances.len() {
53                distances.select_nth_unstable_by(k_actual, |a, b| a.partial_cmp(b).unwrap());
54            }
55            distances[..k_actual].sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
56
57            let dist_sum: T = distances[..k_actual].iter().cloned().sum();
58            dist_sum / T::from_usize(k_actual).unwrap()
59        })
60        .collect();
61
62    // apply gaussian kernel with scaled sigma
63    let mut affinity = Mat::zeros(n, n);
64
65    let three = T::from_f32(3.0).unwrap();
66    let two = T::from_f32(2.0).unwrap();
67
68    affinity
69        .par_col_iter_mut()
70        .enumerate()
71        .for_each(|(j, mut col)| {
72            for i in 0..n {
73                if i == j {
74                    col[i] = T::one();
75                    continue;
76                }
77                let (lo, hi) = if i < j { (i, j) } else { (j, i) };
78                let dij = dist[(i, j)];
79                let sigma = mu * (knn_avg_dist[lo] + knn_avg_dist[hi] + dij) / three;
80
81                col[i] = if sigma < T::epsilon() {
82                    T::zero()
83                } else {
84                    (-dij.powi(2) / (two * sigma.powi(2))).exp()
85                };
86            }
87        });
88
89    affinity
90}
91
92/// KNN thresholding for the matrix
93///
94/// For each sample, keeps only the K most similar neighbors and normalises
95/// their weights to sum to 1. Used to emphasise local structure.
96///
97/// ### Params
98///
99/// * `mat` - The similarity matrix to threshold
100/// * `k` - Number of nearest neighbors to retain per sample
101///
102/// ### Returns
103///
104/// Per row, the retained `(column, normalised weight)` pairs, sorted by column.
105fn knn_threshold<T>(mat: &MatRef<T>, k: usize) -> Vec<Vec<(usize, T)>>
106where
107    T: BixverseFloat + std::iter::Sum,
108{
109    let n = mat.nrows();
110
111    // the matrix is not symmetric after row normalisation; the transpose makes
112    // row i of `mat` a contiguous column
113    let mat_t = mat.transpose().to_owned();
114
115    (0..n)
116        .into_par_iter()
117        .map(|i| {
118            let mut heap = BinaryHeap::with_capacity(k + 1);
119
120            for (j, &sim) in mat_t.col(i).iter().enumerate() {
121                if i == j {
122                    continue;
123                }
124                let entry = (RevOrderedFloat(sim), j);
125                if heap.len() < k {
126                    heap.push(entry);
127                } else if heap.peek().is_some_and(|top| entry < *top) {
128                    heap.pop();
129                    heap.push(entry);
130                }
131            }
132
133            let mut neighbors: Vec<(usize, T)> = heap
134                .into_iter()
135                .map(|elem| (elem.1, elem.0.get_value()))
136                .collect();
137            neighbors.sort_unstable_by_key(|&(j, _)| j);
138
139            let knn_sum: T = neighbors.iter().map(|(_, v)| *v).sum();
140
141            neighbors
142                .into_iter()
143                .map(|(idx, val)| (idx, val / knn_sum))
144                .collect()
145        })
146        .collect()
147}
148
149/// B0 normalisation for SNF update step
150///
151/// Applies modified normalisation where diagonal elements are set to 0.5 and
152/// off-diagonal elements are scaled by alpha. Increases stability during the
153/// fusion process
154///
155/// ### Params
156///
157/// * `mat` - The matrix to normalise.
158/// * `alpha` - Normalisation factor for off-diagonal elements (typically 1.0)
159///
160/// ### Returns
161///
162/// The normalised matrix with diagonal set to 0.5
163fn b0_normalise<T>(mat: &MatRef<T>, alpha: T) -> Mat<T>
164where
165    T: BixverseFloat + std::iter::Sum,
166{
167    let n = mat.nrows();
168    let normalised = normalise_rows_l1(mat);
169
170    let half = T::from_f32(0.5).unwrap();
171    let two = T::from_f32(2.0).unwrap();
172
173    Mat::from_fn(n, n, |i, j| {
174        if i == j {
175            half
176        } else {
177            *normalised.get(i, j) / (two * alpha)
178        }
179    })
180}
181
182/// Calculate Gower distance between columns
183///
184/// I need this one, as I cannot just transpose the other one.
185///
186/// ### Params
187///
188/// * `mat` - The data matrix (features × samples)
189/// * `is_cat` - Boolean vector indicating which rows (features) are categorical
190/// * `ranges` - Optional pre-computed ranges for continuous variables
191///
192/// ### Returns
193///
194/// The Gower distance matrix with values in [0, 1]
195pub fn snf_gower_dist<T>(mat: &MatRef<T>, is_cat: &[bool], ranges: Option<&[T]>) -> Mat<T>
196where
197    T: BixverseFloat,
198{
199    let (nrows, ncols) = mat.shape();
200
201    assert!(
202        is_cat.len() == nrows,
203        "The categorical vector length {} doesn't match features {}",
204        is_cat.len(),
205        nrows
206    );
207
208    // compute ranges in parallel
209    let computed_ranges: Vec<T> = if let Some(r) = ranges {
210        assert!(
211            r.len() == nrows,
212            "The range vector length {} doesn't match features {}",
213            r.len(),
214            nrows
215        );
216
217        r.to_vec()
218    } else {
219        (0..nrows)
220            .into_par_iter()
221            .map(|i| {
222                if is_cat[i] {
223                    T::one()
224                } else {
225                    let mut min_val = T::infinity();
226                    let mut max_val = T::neg_infinity();
227                    for j in 0..ncols {
228                        let val = *mat.get(i, j);
229                        min_val = min_val.min(val);
230                        max_val = max_val.max(val);
231                    }
232                    let range = max_val - min_val;
233                    if range < T::epsilon() {
234                        T::one()
235                    } else {
236                        range
237                    }
238                }
239            })
240            .collect()
241    };
242
243    // continuous features first, so both inner loops run over contiguous
244    // slices with no per-feature branch
245    let order: Vec<usize> = (0..nrows)
246        .filter(|&k| !is_cat[k])
247        .chain((0..nrows).filter(|&k| is_cat[k]))
248        .collect();
249    let n_cont = is_cat.iter().filter(|&&c| !c).count();
250    let ranges_ordered: Vec<T> = order.iter().map(|&k| computed_ranges[k]).collect();
251    let ordered = Mat::from_fn(nrows, ncols, |r, c| mat[(order[r], c)]);
252    let n_features = T::from_usize(nrows).unwrap();
253
254    let mut res = Mat::zeros(ncols, ncols);
255
256    res.par_col_iter_mut().enumerate().for_each(|(j, mut col)| {
257        let col_j = ordered.col_as_slice(j);
258        for i in 0..j {
259            let col_i = ordered.col_as_slice(i);
260            let mut total_dist = T::zero();
261
262            for ((&a, &b), &range) in col_i[..n_cont]
263                .iter()
264                .zip(&col_j[..n_cont])
265                .zip(&ranges_ordered[..n_cont])
266            {
267                total_dist += (a - b).abs() / range;
268            }
269            for (&a, &b) in col_i[n_cont..].iter().zip(&col_j[n_cont..]) {
270                if (a - b).abs() >= T::epsilon() {
271                    total_dist += T::one();
272                }
273            }
274
275            col[i] = total_dist / n_features;
276        }
277    });
278
279    for j in 0..ncols {
280        for i in 0..j {
281            res[(j, i)] = res[(i, j)];
282        }
283    }
284
285    res
286}
287
288////////////////////
289// Main functions //
290////////////////////
291
292/// Generates an affinity matrix for SNF on continuous values
293///
294/// ### Params
295///
296/// * `data` - The underlying data. Assumes the orientation features x samples!
297/// * `distance_type` - One of the implemented distances.
298/// * `k` - Number of neighbours to consider
299/// * `mu` - Controls the Gaussian kernel strength
300/// * `normalise` - Shall the data be normalised prior to distance calculation
301///
302/// ### Returns
303///
304/// The affinity matrix based on continuous values
305pub fn make_affinity_continuous<T>(
306    data: &MatRef<T>,
307    distance_type: &str,
308    k: usize,
309    mu: T,
310    normalise: bool,
311) -> Mat<T>
312where
313    T: BixverseFloat + std::iter::Sum + SimdDistance,
314{
315    let dist_type = parse_distance_type(distance_type).unwrap_or_default();
316
317    let normalised_data = if normalise {
318        scale_matrix_col(data, true)
319    } else {
320        data.to_owned()
321    };
322
323    let dist_mat = match dist_type {
324        DistanceType::L1Norm => column_pairwise_l1_norm(&normalised_data.as_ref()),
325        DistanceType::L2Norm => column_pairwise_l2_norm(&normalised_data.as_ref()),
326        DistanceType::Cosine => column_pairwise_cosine_dist(&normalised_data.as_ref()),
327        DistanceType::Canberra => column_pairwise_canberra_dist(&normalised_data.as_ref()),
328        DistanceType::Correlation => {
329            let cor = column_pairwise_cor(&normalised_data.as_ref(), false);
330            Mat::from_fn(cor.nrows(), cor.ncols(), |i, j| T::one() - cor[(i, j)])
331        }
332    };
333
334    affinity_from_distance(&dist_mat.as_ref(), k, mu)
335}
336
337/// Generates an affinity matrix for SNF on mixed feature types
338///
339/// ### Params
340///
341/// * `data` - The underlying data. Assumes the orientation features x samples!
342/// * `is_cat` - Which of the features are categorical.
343/// * `k` - Number of neighbours to consider
344/// * `mu` - Controls the Gaussian kernel strength
345///
346/// ### Returns
347///
348/// The affinity matrix based on Gower distance
349pub fn make_affinity_mixed<T>(data: &MatRef<T>, is_cat: &[bool], k: usize, mu: T) -> Mat<T>
350where
351    T: BixverseFloat + std::iter::Sum,
352{
353    let dist_mat = snf_gower_dist(data, is_cat, None);
354    affinity_from_distance(&dist_mat.as_ref(), k, mu)
355}
356
357/// Generates an affinity matrix for SNF on categorical features
358///
359/// ### Params
360///
361/// * `data` - The underlying data. Assumes the orientation features x samples!
362/// * `k` - Number of neighbours to consider
363/// * `mu` - Controls the Gaussian kernel strength
364///
365/// ### Returns
366///
367/// The affinity matrix based on Gower distance
368pub fn make_affinity_categorical<T>(data: &MatRef<i32>, k: usize, mu: T) -> Mat<T>
369where
370    T: BixverseFloat + std::iter::Sum,
371{
372    let dist_mat = column_pairwise_hamming_cat(data);
373    affinity_from_distance(&dist_mat.as_ref(), k, mu)
374}
375
376/// Run similarity network fusion
377///
378/// Fuses multiple affinity matrices representing different data types into
379/// a unified similarity network. The algorithm iteratively updates each
380/// affinity matrix by diffusing information through local neighborhoods while
381/// incorporating global structure from other modalities.
382///
383/// ### Params
384///
385/// * `aff_mats` - Slice of matrix references representing the individual
386///   affinity matrices. The dimensions need to be the same and they need to
387///   be symmetric.
388/// * `k` - Number of neighbours to consider.
389/// * `t` - Number of iterations to run the algorithm for.
390/// * `alpha` - Normalisation hyperparameter controlling fusion strength
391///   (typically 1.0)
392///
393/// ### Returns
394///
395/// The adjcaceny matrix of the finally fused network.
396pub fn snf<T>(aff_mats: &[MatRef<T>], k: usize, t: usize, alpha: T) -> Mat<T>
397where
398    T: BixverseFloat + std::iter::Sum,
399{
400    assert!(
401        !aff_mats.is_empty(),
402        "At least one affinity matrix required"
403    );
404
405    let n = aff_mats[0].nrows();
406
407    for (i, mat) in aff_mats.iter().enumerate() {
408        assert_symmetric_mat!(mat);
409        assert!(
410            mat.nrows() == n,
411            "Matrix {} has different size: {} x {} (expected {} x {})",
412            i,
413            mat.nrows(),
414            mat.ncols(),
415            n,
416            n
417        );
418    }
419
420    let m = aff_mats.len();
421
422    let mut aff = aff_mats
423        .par_iter()
424        .map(|mat| normalise_rows_l1(&mat.as_ref()))
425        .collect::<Vec<Mat<T>>>();
426
427    let wk = aff
428        .par_iter()
429        .map(|mat_i| knn_threshold(&mat_i.as_ref(), k))
430        .collect::<Vec<Vec<Vec<(usize, T)>>>>();
431
432    // scratch reused across all updates
433    let mut p_sum: Mat<T> = Mat::zeros(n, n);
434    let mut wk_p: Mat<T> = Mat::zeros(n, n);
435    let mut fused: Mat<T> = Mat::zeros(n, n);
436    let n_others = T::from_f64((m - 1) as f64).unwrap();
437
438    for _ in 0..t {
439        for v in 0..m {
440            // mean of all P matrices except the current one
441            p_sum.par_col_iter_mut().enumerate().for_each(|(c, col)| {
442                let col = col.try_as_col_major_mut().unwrap().as_slice_mut();
443                col.fill(T::zero());
444                for (idx, mat) in aff.iter().enumerate() {
445                    if idx != v {
446                        for (o, &x) in col.iter_mut().zip(mat.col_as_slice(c)) {
447                            *o += x;
448                        }
449                    }
450                }
451                for o in col.iter_mut() {
452                    *o /= n_others;
453                }
454            });
455
456            // wk[v] has k non-zeros per row, so both products are sparse
457            let rows = &wk[v];
458            wk_p.par_col_iter_mut().enumerate().for_each(|(c, col)| {
459                let col = col.try_as_col_major_mut().unwrap().as_slice_mut();
460                let p_col = p_sum.col_as_slice(c);
461                for (o, row) in col.iter_mut().zip(rows) {
462                    let mut acc = T::zero();
463                    for &(j, val) in row {
464                        acc += val * p_col[j];
465                    }
466                    *o = acc;
467                }
468            });
469            fused.par_col_iter_mut().enumerate().for_each(|(l, col)| {
470                let col = col.try_as_col_major_mut().unwrap().as_slice_mut();
471                col.fill(T::zero());
472                for &(j, val) in &rows[l] {
473                    for (o, &x) in col.iter_mut().zip(wk_p.col_as_slice(j)) {
474                        *o += val * x;
475                    }
476                }
477            });
478
479            aff[v] = b0_normalise(&fused.as_ref(), alpha)
480        }
481    }
482
483    // final fusion - mean across all matrices
484    let mut w: Mat<T> = aff
485        .par_iter()
486        .cloned()
487        .reduce(|| Mat::zeros(n, n), |acc, mat| acc + mat);
488
489    w = &w / m as f64;
490
491    w = normalise_rows_l1(&w.as_ref());
492
493    w = (&w + w.transpose()) / 2.0;
494
495    for i in 0..n {
496        w[(i, i)] = T::one();
497    }
498
499    w
500}