Skip to main content

rust_igraph/algorithms/embedding/
adjacency_spectral_embedding.rs

1//! Adjacency spectral embedding (ALGO-EM-002).
2//!
3//! Computes an `no`-dimensional Euclidean representation of the graph
4//! based on the spectral decomposition of its adjacency matrix.
5//!
6//! For undirected graphs, decomposes A = U D U^T (eigendecomposition)
7//! and returns X = U^no (the first `no` eigenvectors). If `scaled`,
8//! multiplies each column by `sqrt(|λ_i|)`.
9//!
10//! Counterpart of `igraph_adjacency_spectral_embedding()` from
11//! `references/igraph/src/misc/embedding.c:872`.
12
13use crate::algorithms::community::lanczos::{EigenWhich, lanczos_top_k};
14use crate::core::{Graph, IgraphError, IgraphResult};
15
16/// Which eigenvalues to select for spectral embedding.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SpectralWhich {
19    /// Largest algebraic eigenvalues.
20    LargestAlgebraic,
21    /// Smallest algebraic eigenvalues.
22    SmallestAlgebraic,
23    /// Largest magnitude eigenvalues.
24    LargestMagnitude,
25}
26
27/// Result of adjacency spectral embedding.
28#[derive(Debug, Clone)]
29pub struct AdjacencySpectralEmbeddingResult {
30    /// Eigenvalues (or singular values) in the order selected by `which`.
31    pub eigenvalues: Vec<f64>,
32    /// Embedding matrix: `embedding[v]` is the `no`-dimensional embedding
33    /// of vertex `v`. Each inner vector has length `no`.
34    pub embedding: Vec<Vec<f64>>,
35}
36
37/// Compute the adjacency spectral embedding of an undirected graph.
38///
39/// Decomposes the (optionally augmented) adjacency matrix
40/// A + diag(cvec) into its top eigenpairs and returns the
41/// eigenvectors as vertex embeddings.
42///
43/// `no`: embedding dimension (number of eigenpairs to compute).
44///
45/// `weights`: optional edge weights. Must have length `ecount` if
46/// provided.
47///
48/// `which`: which eigenvalues to use. [`SpectralWhich::LargestAlgebraic`]
49/// is the default in igraph.
50///
51/// `scaled`: if true, multiply each eigenvector column by
52/// sqrt(|eigenvalue|), giving X = U D^(1/2).
53///
54/// `cvec`: optional diagonal augmentation vector. If `Some(&[c])` (a
55/// single element), `c` is added to every diagonal entry. If
56/// `Some(&[c0, c1, ..., cn-1])` (length = vcount), vertex `i` gets
57/// `c_i` added to its diagonal. Default is zero augmentation.
58///
59/// Edge directions are ignored (the graph is treated as undirected).
60///
61/// # Examples
62///
63/// ```
64/// use rust_igraph::{Graph, adjacency_spectral_embedding, SpectralWhich};
65///
66/// let mut g = Graph::with_vertices(4);
67/// g.add_edge(0, 1).unwrap();
68/// g.add_edge(1, 2).unwrap();
69/// g.add_edge(2, 3).unwrap();
70/// g.add_edge(0, 3).unwrap();
71///
72/// let r = adjacency_spectral_embedding(&g, 2, None, SpectralWhich::LargestAlgebraic,
73///                                       false, None).unwrap();
74/// assert_eq!(r.eigenvalues.len(), 2);
75/// assert_eq!(r.embedding.len(), 4);
76/// assert_eq!(r.embedding[0].len(), 2);
77/// ```
78#[allow(clippy::too_many_arguments)]
79pub fn adjacency_spectral_embedding(
80    graph: &Graph,
81    no: usize,
82    weights: Option<&[f64]>,
83    which: SpectralWhich,
84    scaled: bool,
85    cvec: Option<&[f64]>,
86) -> IgraphResult<AdjacencySpectralEmbeddingResult> {
87    let n = graph.vcount() as usize;
88
89    if no == 0 {
90        return Err(IgraphError::InvalidArgument(
91            "no must be at least 1".to_string(),
92        ));
93    }
94    if no > n {
95        return Err(IgraphError::InvalidArgument(format!(
96            "no ({no}) exceeds vertex count ({n})"
97        )));
98    }
99
100    if let Some(w) = weights {
101        if w.len() != graph.ecount() {
102            return Err(IgraphError::InvalidArgument(format!(
103                "weights length ({}) differs from edge count ({})",
104                w.len(),
105                graph.ecount()
106            )));
107        }
108        for &wv in w {
109            if !wv.is_finite() {
110                return Err(IgraphError::InvalidArgument(
111                    "edge weights must be finite".to_string(),
112                ));
113            }
114        }
115    }
116
117    if let Some(cv) = cvec {
118        if cv.len() != 1 && cv.len() != n {
119            return Err(IgraphError::InvalidArgument(format!(
120                "cvec length ({}) must be 1 or vcount ({n})",
121                cv.len()
122            )));
123        }
124    }
125
126    if n == 0 {
127        return Ok(AdjacencySpectralEmbeddingResult {
128            eigenvalues: Vec::new(),
129            embedding: Vec::new(),
130        });
131    }
132
133    if graph.ecount() == 0 {
134        return Ok(AdjacencySpectralEmbeddingResult {
135            eigenvalues: vec![0.0; no],
136            embedding: vec![vec![0.0; no]; n],
137        });
138    }
139
140    // Build adjacency list (undirected)
141    let adj = build_adjacency(graph)?;
142
143    // Matrix-vector product: y = (A + diag(cvec)) x
144    let matvec = |x: &[f64], y: &mut [f64]| {
145        adjacency_matvec(&adj, weights, cvec, n, x, y);
146    };
147
148    let eigen_which = match which {
149        SpectralWhich::LargestAlgebraic => EigenWhich::LargestAlgebraic,
150        SpectralWhich::SmallestAlgebraic => EigenWhich::SmallestAlgebraic,
151        SpectralWhich::LargestMagnitude => EigenWhich::LargestMagnitude,
152    };
153
154    let max_iter = n.saturating_mul(50).max(1000);
155    let result = lanczos_top_k(n, &matvec, no, eigen_which, max_iter);
156
157    let actual_no = result.eigenvalues.len();
158
159    // Zapsmall eigenvalues
160    let mut eigenvalues = result.eigenvalues;
161    zapsmall_vec(&mut eigenvalues);
162
163    // Build embedding: embedding[v][d] = eigenvector_d[v]
164    let mut embedding = vec![vec![0.0; actual_no]; n];
165    for (d, evec) in result.eigenvectors.iter().enumerate().take(actual_no) {
166        for (v, row) in embedding.iter_mut().enumerate() {
167            row[d] = evec[v];
168        }
169    }
170
171    // Zapsmall the embedding
172    for row in &mut embedding {
173        zapsmall_vec(row);
174    }
175
176    // Scale: X = U D^{1/2} (multiply column d by sqrt(|λ_d|))
177    if scaled {
178        for (d, &eval) in eigenvalues.iter().enumerate().take(actual_no) {
179            let scale = eval.abs().sqrt();
180            for row in &mut embedding {
181                row[d] *= scale;
182            }
183        }
184    }
185
186    Ok(AdjacencySpectralEmbeddingResult {
187        eigenvalues,
188        embedding,
189    })
190}
191
192// ─── Internal helpers ────────────────────────────────────────────
193
194type AdjList = Vec<Vec<(usize, usize)>>; // adj[v] = [(neighbor, edge_id), ...]
195
196fn build_adjacency(graph: &Graph) -> IgraphResult<AdjList> {
197    let n = graph.vcount() as usize;
198    let mut adj: AdjList = vec![Vec::new(); n];
199    for eid in 0..graph.ecount() {
200        #[allow(clippy::cast_possible_truncation)]
201        let eid32 = eid as u32;
202        let s = graph.edge_source(eid32)? as usize;
203        let t = graph.edge_target(eid32)? as usize;
204        adj[s].push((t, eid));
205        if s != t {
206            adj[t].push((s, eid));
207        }
208    }
209    Ok(adj)
210}
211
212fn adjacency_matvec(
213    adj: &AdjList,
214    weights: Option<&[f64]>,
215    cvec: Option<&[f64]>,
216    n: usize,
217    x: &[f64],
218    y: &mut [f64],
219) {
220    for i in 0..n {
221        y[i] = 0.0;
222        for &(nei, eid) in &adj[i] {
223            let w = match weights {
224                Some(wt) => wt[eid],
225                None => 1.0,
226            };
227            y[i] += w * x[nei];
228        }
229        // Diagonal augmentation
230        if let Some(cv) = cvec {
231            let c = if cv.len() == 1 { cv[0] } else { cv[i] };
232            y[i] += c * x[i];
233        }
234    }
235}
236
237fn zapsmall_vec(v: &mut [f64]) {
238    if v.is_empty() {
239        return;
240    }
241    let max_abs = v.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
242    if max_abs == 0.0 {
243        return;
244    }
245    let threshold = max_abs * f64::EPSILON.sqrt();
246    for x in v {
247        if x.abs() < threshold {
248            *x = 0.0;
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn empty_graph() {
259        let g = Graph::with_vertices(0);
260        let r =
261            adjacency_spectral_embedding(&g, 1, None, SpectralWhich::LargestAlgebraic, false, None);
262        assert!(r.is_err()); // no > n
263    }
264
265    #[test]
266    fn no_edges() {
267        let g = Graph::with_vertices(4);
268        let r =
269            adjacency_spectral_embedding(&g, 2, None, SpectralWhich::LargestAlgebraic, false, None)
270                .unwrap();
271        assert_eq!(r.eigenvalues.len(), 2);
272        assert_eq!(r.embedding.len(), 4);
273        for &e in &r.eigenvalues {
274            assert!(
275                (e).abs() < 1e-10,
276                "eigenvalue should be 0 for edgeless graph"
277            );
278        }
279    }
280
281    #[test]
282    fn path_graph_2d() {
283        // Path P4: adjacency eigenvalues are ±(1+√5)/2 and ±(√5-1)/2
284        let mut g = Graph::with_vertices(4);
285        g.add_edge(0, 1).unwrap();
286        g.add_edge(1, 2).unwrap();
287        g.add_edge(2, 3).unwrap();
288
289        let r =
290            adjacency_spectral_embedding(&g, 2, None, SpectralWhich::LargestAlgebraic, false, None)
291                .unwrap();
292        assert_eq!(r.eigenvalues.len(), 2);
293        // Eigenvalues should be positive (largest algebraic of a path)
294        assert!(r.eigenvalues[0] > 0.0, "got {}", r.eigenvalues[0]);
295        assert!(r.eigenvalues[1] > 0.0, "got {}", r.eigenvalues[1]);
296        // First eigenvalue should be the golden ratio (1+√5)/2 ≈ 1.618
297        #[allow(unknown_lints, clippy::manual_midpoint)]
298        let golden = (1.0 + 5.0_f64.sqrt()) / 2.0;
299        assert!(
300            (r.eigenvalues[0] - golden).abs() < 1e-4,
301            "expected {golden}, got {}",
302            r.eigenvalues[0]
303        );
304    }
305
306    #[test]
307    fn complete_graph_k4() {
308        // K4: adjacency eigenvalues are 3 (×1) and -1 (×3)
309        let mut g = Graph::with_vertices(4);
310        for i in 0..4u32 {
311            for j in (i + 1)..4 {
312                g.add_edge(i, j).unwrap();
313            }
314        }
315        let r =
316            adjacency_spectral_embedding(&g, 1, None, SpectralWhich::LargestAlgebraic, false, None)
317                .unwrap();
318        assert!(
319            (r.eigenvalues[0] - 3.0).abs() < 1e-4,
320            "expected 3.0, got {}",
321            r.eigenvalues[0]
322        );
323    }
324
325    #[test]
326    fn scaled_embedding() {
327        let mut g = Graph::with_vertices(4);
328        for i in 0..4u32 {
329            for j in (i + 1)..4 {
330                g.add_edge(i, j).unwrap();
331            }
332        }
333        let r_unscaled =
334            adjacency_spectral_embedding(&g, 1, None, SpectralWhich::LargestAlgebraic, false, None)
335                .unwrap();
336        let r_scaled =
337            adjacency_spectral_embedding(&g, 1, None, SpectralWhich::LargestAlgebraic, true, None)
338                .unwrap();
339
340        let scale = r_unscaled.eigenvalues[0].abs().sqrt();
341        for v in 0..4 {
342            let expected = r_unscaled.embedding[v][0] * scale;
343            assert!(
344                (r_scaled.embedding[v][0] - expected).abs() < 1e-8,
345                "vertex {v}: expected {expected}, got {}",
346                r_scaled.embedding[v][0]
347            );
348        }
349    }
350
351    #[test]
352    fn cvec_scalar_augmentation() {
353        // Adding c to diagonal shifts all eigenvalues by c
354        let mut g = Graph::with_vertices(4);
355        for i in 0..4u32 {
356            for j in (i + 1)..4 {
357                g.add_edge(i, j).unwrap();
358            }
359        }
360        let r_plain =
361            adjacency_spectral_embedding(&g, 1, None, SpectralWhich::LargestAlgebraic, false, None)
362                .unwrap();
363        let r_shifted = adjacency_spectral_embedding(
364            &g,
365            1,
366            None,
367            SpectralWhich::LargestAlgebraic,
368            false,
369            Some(&[2.0]),
370        )
371        .unwrap();
372        // λ_shifted = λ_plain + 2.0
373        assert!(
374            (r_shifted.eigenvalues[0] - r_plain.eigenvalues[0] - 2.0).abs() < 1e-4,
375            "expected shift of 2.0, got {} vs {}",
376            r_shifted.eigenvalues[0],
377            r_plain.eigenvalues[0]
378        );
379    }
380
381    #[test]
382    fn smallest_algebraic() {
383        // K4: eigenvalues 3, -1, -1, -1. Smallest algebraic = -1
384        let mut g = Graph::with_vertices(4);
385        for i in 0..4u32 {
386            for j in (i + 1)..4 {
387                g.add_edge(i, j).unwrap();
388            }
389        }
390        let r = adjacency_spectral_embedding(
391            &g,
392            1,
393            None,
394            SpectralWhich::SmallestAlgebraic,
395            false,
396            None,
397        )
398        .unwrap();
399        assert!(
400            (r.eigenvalues[0] - (-1.0)).abs() < 1e-4,
401            "expected -1.0, got {}",
402            r.eigenvalues[0]
403        );
404    }
405
406    #[test]
407    fn weighted_graph() {
408        // Star graph S3 with weights: center=0, leaves=1,2,3
409        // A = [[0,w,w,w],[w,0,0,0],[w,0,0,0],[w,0,0,0]]
410        // Eigenvalues: ±w√3, 0, 0
411        let mut g = Graph::with_vertices(4);
412        g.add_edge(0, 1).unwrap();
413        g.add_edge(0, 2).unwrap();
414        g.add_edge(0, 3).unwrap();
415        let weights = vec![2.0, 2.0, 2.0];
416        let r = adjacency_spectral_embedding(
417            &g,
418            1,
419            Some(&weights),
420            SpectralWhich::LargestAlgebraic,
421            false,
422            None,
423        )
424        .unwrap();
425        let expected = 2.0 * 3.0_f64.sqrt();
426        assert!(
427            (r.eigenvalues[0] - expected).abs() < 1e-4,
428            "expected {expected}, got {}",
429            r.eigenvalues[0]
430        );
431    }
432
433    #[test]
434    fn invalid_no() {
435        let g = Graph::with_vertices(3);
436        let r =
437            adjacency_spectral_embedding(&g, 5, None, SpectralWhich::LargestAlgebraic, false, None);
438        assert!(r.is_err());
439    }
440
441    #[test]
442    fn embedding_dimension() {
443        let mut g = Graph::with_vertices(6);
444        g.add_edge(0, 1).unwrap();
445        g.add_edge(1, 2).unwrap();
446        g.add_edge(2, 3).unwrap();
447        g.add_edge(3, 4).unwrap();
448        g.add_edge(4, 5).unwrap();
449        let r =
450            adjacency_spectral_embedding(&g, 3, None, SpectralWhich::LargestAlgebraic, false, None)
451                .unwrap();
452        assert_eq!(r.eigenvalues.len(), 3);
453        assert_eq!(r.embedding.len(), 6);
454        for row in &r.embedding {
455            assert_eq!(row.len(), 3);
456        }
457    }
458}