Skip to main content

oxicuda_graph/cluster/
spectral.rs

1//! Spectral clustering via graph Laplacian eigenvectors (Von Luxburg 2007).
2//!
3//! The algorithm:
4//! 1. Build (or accept) an `n×n` symmetric affinity matrix **A**.
5//! 2. Compute the degree vector `D[i] = Σ_j A[i,j]`.
6//! 3. Form the symmetrically normalised matrix `M = D^{-1/2} A D^{-1/2}`.
7//!    The smallest eigenvectors of `L_sym = I − M` are the largest of `M`.
8//! 4. Extract the top-k eigenvectors of `M` via **power iteration with deflation**
9//!    (Gram-Schmidt orthogonalisation between each eigenvector).
10//! 5. Row-normalise the embedding matrix `U ∈ ℝ^{n × k}`.
11//! 6. Run Lloyd's k-means on the rows of `U`.
12
13use crate::error::{GraphError, GraphResult};
14
15// ─── Configuration ────────────────────────────────────────────────────────────
16
17/// Hyper-parameters for spectral clustering.
18#[derive(Debug, Clone)]
19pub struct SpectralConfig {
20    /// Number of output clusters.
21    pub n_clusters: usize,
22    /// Number of eigenvectors to compute (typically == `n_clusters`).
23    pub n_eigenvectors: usize,
24    /// RBF kernel parameter `γ`: `A[i,j] = exp(−γ ‖x_i − x_j‖²)`.
25    pub gamma: f64,
26    /// Number of Lloyd's k-means iterations.
27    pub n_iter_kmeans: usize,
28    /// Power-iteration steps per eigenvector.
29    pub n_iter_power: usize,
30}
31
32impl Default for SpectralConfig {
33    fn default() -> Self {
34        Self {
35            n_clusters: 2,
36            n_eigenvectors: 2,
37            gamma: 1.0,
38            n_iter_kmeans: 50,
39            n_iter_power: 100,
40        }
41    }
42}
43
44// ─── Result type ─────────────────────────────────────────────────────────────
45
46/// Output of a spectral clustering run.
47pub struct SpectralClustering {
48    labels: Vec<usize>,
49    eigenvalues: Vec<f64>,
50    n_clusters: usize,
51}
52
53impl SpectralClustering {
54    // ── Constructors ──────────────────────────────────────────────────────────
55
56    /// Cluster `n` points from an `n×n` affinity matrix (row-major, flat slice).
57    ///
58    /// # Errors
59    /// - [`GraphError::InvalidPlan`] for shape / config inconsistencies.
60    pub fn fit_affinity(affinity: &[f64], n: usize, config: &SpectralConfig) -> GraphResult<Self> {
61        validate_config(n, config)?;
62        if affinity.len() != n * n {
63            return Err(GraphError::InvalidPlan(format!(
64                "affinity length {} != n*n {}",
65                affinity.len(),
66                n * n
67            )));
68        }
69
70        // Step 1: degree vector
71        let mut degree = vec![0.0_f64; n];
72        for i in 0..n {
73            let s: f64 = affinity[i * n..(i + 1) * n].iter().sum();
74            degree[i] = s;
75        }
76
77        // Step 2: D^{-1/2}
78        let d_inv_sqrt: Vec<f64> = degree
79            .iter()
80            .map(|&d| if d > 1e-14 { 1.0 / d.sqrt() } else { 0.0 })
81            .collect();
82
83        // Step 3: M = D^{-1/2} A D^{-1/2}  (symmetric, stored as full n×n)
84        let mut m = vec![0.0_f64; n * n];
85        for i in 0..n {
86            for j in 0..n {
87                m[i * n + j] = d_inv_sqrt[i] * affinity[i * n + j] * d_inv_sqrt[j];
88            }
89        }
90
91        // Step 4: top-k eigenvectors of M via power iteration + deflation
92        let k = config.n_eigenvectors;
93        let (eigvecs, eigenvalues) = power_iteration_deflation(&m, n, k, config.n_iter_power);
94
95        // Step 5: build embedding U [n × k], row-normalise
96        let mut u = eigvecs; // already [n × k] column-major: col_i is eigvec_i
97        // Transpose to row-major [n × k]
98        let mut u_row = vec![0.0_f64; n * k];
99        for i in 0..n {
100            for c in 0..k {
101                u_row[i * k + c] = u[c * n + i];
102            }
103        }
104        // Row-normalise
105        for i in 0..n {
106            let row = &mut u_row[i * k..(i + 1) * k];
107            let norm = row.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-12);
108            for v in row.iter_mut() {
109                *v /= norm;
110            }
111        }
112
113        // Step 6: k-means
114        let seed = affinity
115            .iter()
116            .take(16)
117            .fold(0u64, |acc, &v| acc.wrapping_add(v.to_bits()));
118        let labels = kmeans(&u_row, n, k, config.n_clusters, config.n_iter_kmeans, seed);
119
120        // Eigenvalues stored as eigenvalues of M (not of L_sym)
121        let eigenvalues_out: Vec<f64> = eigenvalues;
122        // Reuse mutable u binding to avoid "unused mut" warning:
123        u = Vec::new();
124        let _ = u;
125
126        Ok(Self {
127            labels,
128            eigenvalues: eigenvalues_out,
129            n_clusters: config.n_clusters,
130        })
131    }
132
133    /// Cluster `n` data points from a `[n × dim]` row-major data matrix,
134    /// using an RBF affinity: `A[i,j] = exp(−γ ‖x_i − x_j‖²)`.
135    ///
136    /// # Errors
137    /// - [`GraphError::InvalidPlan`] for shape / config inconsistencies.
138    pub fn fit_data(
139        data: &[f64],
140        n: usize,
141        dim: usize,
142        config: &SpectralConfig,
143    ) -> GraphResult<Self> {
144        if dim == 0 {
145            return Err(GraphError::InvalidPlan("dim must be > 0".to_owned()));
146        }
147        if data.len() != n * dim {
148            return Err(GraphError::InvalidPlan(format!(
149                "data length {} != n*dim {}",
150                data.len(),
151                n * dim
152            )));
153        }
154
155        // Build RBF affinity matrix
156        let mut affinity = vec![0.0_f64; n * n];
157        for i in 0..n {
158            for j in 0..n {
159                let sq_dist: f64 = (0..dim)
160                    .map(|d| {
161                        let diff = data[i * dim + d] - data[j * dim + d];
162                        diff * diff
163                    })
164                    .sum();
165                affinity[i * n + j] = (-config.gamma * sq_dist).exp();
166            }
167        }
168
169        Self::fit_affinity(&affinity, n, config)
170    }
171
172    // ── Accessors ─────────────────────────────────────────────────────────────
173
174    /// Cluster label for each point (length `n`, values in `[0, n_clusters)`).
175    #[must_use]
176    pub fn labels(&self) -> &[usize] {
177        &self.labels
178    }
179
180    /// Eigenvalues of the normalised affinity matrix `M = D^{-1/2} A D^{-1/2}`
181    /// for the selected eigenvectors (descending order).
182    #[must_use]
183    pub fn eigenvalues(&self) -> &[f64] {
184        &self.eigenvalues
185    }
186
187    /// Number of clusters.
188    #[must_use]
189    pub fn n_clusters(&self) -> usize {
190        self.n_clusters
191    }
192}
193
194// ─── Helpers ─────────────────────────────────────────────────────────────────
195
196/// Validate configuration against the number of points.
197fn validate_config(n: usize, config: &SpectralConfig) -> GraphResult<()> {
198    if n == 0 {
199        return Err(GraphError::InvalidPlan("n must be > 0".to_owned()));
200    }
201    if config.n_clusters == 0 || config.n_clusters > n {
202        return Err(GraphError::InvalidPlan(format!(
203            "n_clusters {} out of range [1, {}]",
204            config.n_clusters, n
205        )));
206    }
207    if config.n_eigenvectors == 0 {
208        return Err(GraphError::InvalidPlan(
209            "n_eigenvectors must be > 0".to_owned(),
210        ));
211    }
212    Ok(())
213}
214
215/// Matrix-vector product `y = A x` for symmetric `n×n` matrix `A` (row-major).
216fn matvec(a: &[f64], x: &[f64], n: usize) -> Vec<f64> {
217    let mut y = vec![0.0_f64; n];
218    for i in 0..n {
219        let mut s = 0.0_f64;
220        for j in 0..n {
221            s += a[i * n + j] * x[j];
222        }
223        y[i] = s;
224    }
225    y
226}
227
228/// Dot product of two vectors.
229#[inline]
230fn dot(a: &[f64], b: &[f64]) -> f64 {
231    a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
232}
233
234/// L2-normalise a vector in-place; returns the original norm.
235fn l2_normalise(v: &mut [f64]) -> f64 {
236    let norm = dot(v, v).sqrt().max(1e-14);
237    for x in v.iter_mut() {
238        *x /= norm;
239    }
240    norm
241}
242
243/// Power iteration with Gram-Schmidt deflation.
244///
245/// Returns `(eigvecs, eigenvalues)` where `eigvecs` is stored **column-major**
246/// as a flat `[k × n]` slice (i.e. `eigvecs[c * n..][..n]` is eigenvector `c`).
247fn power_iteration_deflation(m: &[f64], n: usize, k: usize, n_iter: usize) -> (Vec<f64>, Vec<f64>) {
248    let k_actual = k.min(n);
249    let mut eigvecs = Vec::with_capacity(k_actual * n);
250    let mut eigenvalues = Vec::with_capacity(k_actual);
251
252    for c in 0..k_actual {
253        // Initialise with a deterministic non-zero vector
254        let mut v: Vec<f64> = (0..n)
255            .map(|i| if i == c % n { 1.0 } else { 0.01 })
256            .collect();
257        l2_normalise(&mut v);
258
259        let mut eigenvalue = 0.0_f64;
260        for _ in 0..n_iter {
261            // Power step: v ← M v
262            let mut mv = matvec(m, &v, n);
263
264            // Deflate by all previously found eigenvectors (Gram-Schmidt)
265            for prev in 0..c {
266                let prev_vec = &eigvecs[prev * n..(prev + 1) * n];
267                let coeff = dot(&mv, prev_vec);
268                for (x, &pv) in mv.iter_mut().zip(prev_vec.iter()) {
269                    *x -= coeff * pv;
270                }
271            }
272
273            eigenvalue = l2_normalise(&mut mv);
274            v = mv;
275        }
276
277        eigvecs.extend_from_slice(&v);
278        eigenvalues.push(eigenvalue);
279    }
280
281    (eigvecs, eigenvalues)
282}
283
284/// Simple Lloyd's k-means on rows of `u` (`n × k_embed` row-major).
285/// Returns cluster label for each of the `n` rows.
286fn kmeans(
287    u: &[f64],
288    n: usize,
289    k_embed: usize,
290    k_clusters: usize,
291    n_iter: usize,
292    seed: u64,
293) -> Vec<usize> {
294    if k_clusters == 0 || n == 0 {
295        return vec![0; n];
296    }
297
298    // Deterministic initialisation: pick k_clusters evenly-spaced row indices
299    let mut centroids = vec![0.0_f64; k_clusters * k_embed];
300    let step = (n / k_clusters).max(1);
301    // Use seed to offset start
302    let start = (seed as usize) % n.max(1);
303    for c in 0..k_clusters {
304        let row_idx = (start + c * step) % n;
305        for d in 0..k_embed {
306            centroids[c * k_embed + d] = u[row_idx * k_embed + d];
307        }
308    }
309
310    let mut labels = vec![0_usize; n];
311
312    for _iter in 0..n_iter {
313        // Assignment step
314        let mut changed = false;
315        for i in 0..n {
316            let row = &u[i * k_embed..(i + 1) * k_embed];
317            let mut best = 0_usize;
318            let mut best_dist = f64::INFINITY;
319            for c in 0..k_clusters {
320                let centroid = &centroids[c * k_embed..(c + 1) * k_embed];
321                let dist: f64 = row
322                    .iter()
323                    .zip(centroid.iter())
324                    .map(|(&a, &b)| (a - b) * (a - b))
325                    .sum();
326                if dist < best_dist {
327                    best_dist = dist;
328                    best = c;
329                }
330            }
331            if labels[i] != best {
332                labels[i] = best;
333                changed = true;
334            }
335        }
336
337        if !changed {
338            break;
339        }
340
341        // Update step
342        let mut new_centroids = vec![0.0_f64; k_clusters * k_embed];
343        let mut counts = vec![0_usize; k_clusters];
344        for i in 0..n {
345            let c = labels[i];
346            counts[c] += 1;
347            let row = &u[i * k_embed..(i + 1) * k_embed];
348            for d in 0..k_embed {
349                new_centroids[c * k_embed + d] += row[d];
350            }
351        }
352        for c in 0..k_clusters {
353            if counts[c] > 0 {
354                let cnt = counts[c] as f64;
355                for d in 0..k_embed {
356                    new_centroids[c * k_embed + d] /= cnt;
357                }
358            } else {
359                // Empty cluster: reinitialise to a random point
360                let row_idx = c % n;
361                for d in 0..k_embed {
362                    new_centroids[c * k_embed + d] = u[row_idx * k_embed + d];
363                }
364            }
365        }
366        centroids = new_centroids;
367    }
368
369    labels
370}
371
372// ─── Tests ────────────────────────────────────────────────────────────────────
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn two_block_affinity(n: usize) -> Vec<f64> {
379        // Two equal blocks with high within-block, zero between-block affinity
380        let half = n / 2;
381        let mut a = vec![0.0_f64; n * n];
382        for i in 0..n {
383            for j in 0..n {
384                let same_block = (i < half && j < half) || (i >= half && j >= half);
385                a[i * n + j] = if same_block { 1.0 } else { 0.0 };
386            }
387        }
388        a
389    }
390
391    // 1. Output has exactly n labels
392    #[test]
393    fn labels_len() {
394        let n = 6;
395        let a = two_block_affinity(n);
396        let cfg = SpectralConfig {
397            n_clusters: 2,
398            n_eigenvectors: 2,
399            ..Default::default()
400        };
401        let sc =
402            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
403        assert_eq!(sc.labels().len(), n);
404    }
405
406    // 2. All labels are in [0, n_clusters)
407    #[test]
408    fn labels_in_range() {
409        let n = 8;
410        let a = two_block_affinity(n);
411        let cfg = SpectralConfig {
412            n_clusters: 2,
413            n_eigenvectors: 2,
414            ..Default::default()
415        };
416        let sc =
417            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
418        for &l in sc.labels() {
419            assert!(l < 2, "label {l} out of range");
420        }
421    }
422
423    // 3. Two clearly separated groups → points within each group share a label
424    #[test]
425    fn two_clusters_separated() {
426        let n = 8;
427        let a = two_block_affinity(n);
428        let cfg = SpectralConfig {
429            n_clusters: 2,
430            n_eigenvectors: 2,
431            n_iter_power: 200,
432            n_iter_kmeans: 100,
433            ..Default::default()
434        };
435        let sc =
436            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
437        let labels = sc.labels();
438        let half = n / 2;
439        // First half should all share one label
440        let label_0 = labels[0];
441        for &l in &labels[..half] {
442            assert_eq!(l, label_0, "first-half labels should match");
443        }
444        // Second half all share another label
445        let label_1 = labels[half];
446        for &l in &labels[half..] {
447            assert_eq!(l, label_1, "second-half labels should match");
448        }
449        // The two groups must have different labels
450        assert_ne!(label_0, label_1);
451    }
452
453    // 4. Single cluster: all labels == 0
454    #[test]
455    fn single_cluster() {
456        let n = 5;
457        let a = vec![1.0_f64; n * n]; // fully connected
458        let cfg = SpectralConfig {
459            n_clusters: 1,
460            n_eigenvectors: 1,
461            ..Default::default()
462        };
463        let sc =
464            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
465        for &l in sc.labels() {
466            assert_eq!(l, 0);
467        }
468    }
469
470    // 5. Symmetric affinity → same result as transposed (trivially true since input is symmetric)
471    #[test]
472    fn affinity_symmetric() {
473        let n = 4;
474        let a = two_block_affinity(n);
475        // Transpose (which equals itself for a symmetric matrix)
476        let mut at = vec![0.0_f64; n * n];
477        for i in 0..n {
478            for j in 0..n {
479                at[j * n + i] = a[i * n + j];
480            }
481        }
482        let cfg = SpectralConfig::default();
483        let sc1 =
484            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
485        let sc2 =
486            SpectralClustering::fit_affinity(&at, n, &cfg).expect("fit_affinity should succeed");
487        // Both should produce identical label assignments (same affinity)
488        assert_eq!(sc1.labels(), sc2.labels());
489    }
490
491    // 6. n_clusters=1 works without panic
492    #[test]
493    fn n_clusters_1_works() {
494        let n = 4;
495        let a = two_block_affinity(n);
496        let cfg = SpectralConfig {
497            n_clusters: 1,
498            n_eigenvectors: 1,
499            ..Default::default()
500        };
501        let sc =
502            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
503        assert_eq!(sc.n_clusters(), 1);
504        assert!(sc.labels().iter().all(|&l| l == 0));
505    }
506
507    // 7. Diagonal values don't affect cluster structure
508    //    (zeroing diagonal shouldn't change which points are grouped together)
509    #[test]
510    fn affinity_diagonal_ignored() {
511        let n = 6;
512        let mut a = two_block_affinity(n);
513        // Make diagonal 100×  instead of 1×
514        for i in 0..n {
515            a[i * n + i] = 100.0;
516        }
517        let cfg = SpectralConfig {
518            n_clusters: 2,
519            n_eigenvectors: 2,
520            ..Default::default()
521        };
522        let sc =
523            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
524        assert_eq!(sc.labels().len(), n);
525        for &l in sc.labels() {
526            assert!(l < 2);
527        }
528    }
529
530    // 8. Eigenvalues are all finite
531    #[test]
532    fn eigenvalues_finite() {
533        let n = 6;
534        let a = two_block_affinity(n);
535        let cfg = SpectralConfig {
536            n_clusters: 2,
537            n_eigenvectors: 2,
538            ..Default::default()
539        };
540        let sc =
541            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
542        for &ev in sc.eigenvalues() {
543            assert!(ev.is_finite(), "eigenvalue {ev} is not finite");
544        }
545    }
546
547    // 9. Works when n >> n_eigenvectors
548    #[test]
549    fn n_gt_n_eigenvectors_ok() {
550        let n = 10;
551        let a = two_block_affinity(n);
552        let cfg = SpectralConfig {
553            n_clusters: 2,
554            n_eigenvectors: 2,
555            ..Default::default()
556        };
557        let sc =
558            SpectralClustering::fit_affinity(&a, n, &cfg).expect("fit_affinity should succeed");
559        assert_eq!(sc.labels().len(), n);
560    }
561
562    // 10. fit_data from RBF affinity
563    #[test]
564    fn fit_data_rbf() {
565        // Two well-separated clusters in 2D
566        let n = 8;
567        let dim = 2;
568        let mut data = vec![0.0_f64; n * dim];
569        for i in 0..4 {
570            data[i * dim] = i as f64 * 0.1;
571            data[i * dim + 1] = 0.0;
572        }
573        for i in 4..8 {
574            data[i * dim] = 10.0 + (i - 4) as f64 * 0.1;
575            data[i * dim + 1] = 0.0;
576        }
577        let cfg = SpectralConfig {
578            n_clusters: 2,
579            n_eigenvectors: 2,
580            gamma: 1.0,
581            n_iter_kmeans: 100,
582            n_iter_power: 200,
583        };
584        let sc =
585            SpectralClustering::fit_data(&data, n, dim, &cfg).expect("fit_data should succeed");
586        assert_eq!(sc.labels().len(), n);
587        for &l in sc.labels() {
588            assert!(l < 2);
589        }
590    }
591}