Skip to main content

rust_igraph/algorithms/community/
leading_eigenvector.rs

1//! Leading eigenvector community detection (ALGO-CO-017).
2//!
3//! Newman's leading eigenvector method: recursively bisect the graph
4//! by the sign of the leading eigenvector of the modularity matrix
5//! **B** = **A** − **k** **k**^T / (2m), restricted to each community.
6//!
7//! Reference: MEJ Newman, "Finding community structure in networks
8//! using the eigenvectors of matrices", Phys Rev E 74:036104 (2006).
9//!
10//! Counterpart of `igraph_community_leading_eigenvector()` from
11//! `references/igraph/src/community/leading_eigenvector.c:331`.
12
13use std::collections::VecDeque;
14
15use crate::algorithms::community::lanczos::lanczos_largest;
16use crate::core::rng::SplitMix64;
17use crate::core::{Graph, IgraphError, IgraphResult};
18
19/// Result of the leading eigenvector community detection.
20#[derive(Debug, Clone)]
21pub struct LeadingEigenvectorResult {
22    /// Community assignment for each vertex.
23    pub membership: Vec<u32>,
24    /// Eigenvalue found at each split step (positive means a split
25    /// occurred; non-positive means the split was rejected).
26    pub eigenvalues: Vec<f64>,
27    /// Merges matrix (same format as dendrogram merges): each entry
28    /// `(a, b)` means communities `a` and `b` were merged (in reverse
29    /// of the split order). Use with `le_community_to_membership`.
30    pub merges: Vec<(u32, u32)>,
31    /// Final modularity of the partition.
32    pub modularity: f64,
33}
34
35/// Detect communities using Newman's leading eigenvector method.
36///
37/// Repeatedly bisects communities by the sign of the leading
38/// eigenvector of the restricted modularity matrix. Stops when no
39/// further split increases modularity, or after `steps` splits.
40///
41/// Edge directions are ignored (the graph is treated as undirected).
42///
43/// `steps`: maximum number of split steps. Pass `None` for
44/// `vcount - 1` (exhaustive).
45///
46/// # Examples
47///
48/// ```
49/// use rust_igraph::{Graph, leading_eigenvector};
50///
51/// // Barbell graph: two triangles connected by a bridge
52/// let mut g = Graph::with_vertices(6);
53/// // Triangle 1
54/// g.add_edge(0, 1).unwrap();
55/// g.add_edge(1, 2).unwrap();
56/// g.add_edge(0, 2).unwrap();
57/// // Triangle 2
58/// g.add_edge(3, 4).unwrap();
59/// g.add_edge(4, 5).unwrap();
60/// g.add_edge(3, 5).unwrap();
61/// // Bridge
62/// g.add_edge(2, 3).unwrap();
63///
64/// let result = leading_eigenvector(&g, None, None).unwrap();
65/// // Should find 2 communities (one per triangle)
66/// assert_eq!(result.membership[0], result.membership[1]);
67/// assert_eq!(result.membership[3], result.membership[4]);
68/// assert_ne!(result.membership[0], result.membership[3]);
69/// ```
70#[allow(clippy::too_many_lines)]
71pub fn leading_eigenvector(
72    graph: &Graph,
73    weights: Option<&[f64]>,
74    steps: Option<u32>,
75) -> IgraphResult<LeadingEigenvectorResult> {
76    let n = graph.vcount() as usize;
77    if n == 0 {
78        return Ok(LeadingEigenvectorResult {
79            membership: Vec::new(),
80            eigenvalues: Vec::new(),
81            merges: Vec::new(),
82            modularity: 0.0,
83        });
84    }
85
86    let ecount = graph.ecount();
87    if let Some(w) = weights {
88        if w.len() != ecount {
89            return Err(IgraphError::InvalidArgument(format!(
90                "weights length ({}) differs from edge count ({ecount})",
91                w.len()
92            )));
93        }
94        for &wv in w {
95            if !wv.is_finite() {
96                return Err(IgraphError::InvalidArgument(
97                    "edge weights must be finite".to_string(),
98                ));
99            }
100        }
101    }
102
103    let max_steps = match steps {
104        Some(s) => s as usize,
105        None => {
106            if n > 0 {
107                n - 1
108            } else {
109                0
110            }
111        }
112    };
113
114    // Build adjacency list (treat as undirected: include both directions)
115    let adj = build_adjacency(graph);
116
117    // Degree / strength per vertex
118    let (deg_or_strength, two_m) = compute_degrees(graph, weights, &adj);
119
120    if two_m <= 0.0 {
121        return Ok(LeadingEigenvectorResult {
122            membership: vec![0; n],
123            eigenvalues: Vec::new(),
124            merges: Vec::new(),
125            modularity: 0.0,
126        });
127    }
128
129    // Start from weakly connected components
130    let cc = crate::algorithms::connectivity::components::connected_components(graph)?;
131    let mut membership: Vec<u32> = cc.membership.clone();
132    let mut communities = cc.count;
133
134    let mut eigenvalues_out = Vec::new();
135    let mut merges_out = Vec::new();
136
137    // Queue of communities to try splitting (depth-first via stack)
138    let mut to_split: VecDeque<u32> = VecDeque::new();
139    for c in 0..communities {
140        let size = membership.iter().filter(|&&m| m == c).count();
141        if size > 2 {
142            to_split.push_back(c);
143        }
144    }
145
146    // Record initial component merges (mirrors upstream)
147    for c in 1..communities {
148        merges_out.push((c - 1, c));
149        eigenvalues_out.push(f64::NAN);
150    }
151    let mut steps_taken = (communities as usize).saturating_sub(1);
152
153    let mut rng = SplitMix64::new(42);
154
155    while let Some(comm) = to_split.pop_back() {
156        if steps_taken >= max_steps {
157            break;
158        }
159
160        // Collect vertex indices in this community
161        let idx: Vec<usize> = (0..n).filter(|&i| membership[i] == comm).collect();
162        let size = idx.len();
163
164        steps_taken += 1;
165
166        if size <= 2 {
167            continue;
168        }
169
170        // Build reverse mapping: vertex -> position in community
171        let mut idx2 = vec![0usize; n];
172        for (pos, &v) in idx.iter().enumerate() {
173            idx2[v] = pos;
174        }
175
176        // The modularity matrix-vector product for this community
177        let matvec = |x: &[f64], y: &mut [f64]| {
178            modularity_matvec(
179                graph,
180                weights,
181                &adj,
182                &deg_or_strength,
183                two_m,
184                &membership,
185                comm,
186                &idx,
187                &idx2,
188                x,
189                y,
190            );
191        };
192
193        // Random start vector: alternating ±1 with small perturbation
194        let mut start_vec = vec![0.0_f64; size];
195        for (i, sv) in start_vec.iter_mut().enumerate() {
196            let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
197            let perturb = (rng.gen_unit() - 0.5) * 0.2;
198            *sv = sign + perturb;
199        }
200        // Shuffle
201        for i in (1..size).rev() {
202            let j = rng.gen_index(i + 1);
203            start_vec.swap(i, j);
204        }
205
206        let result = lanczos_largest(size, &matvec, &mut start_vec, 10000);
207
208        // Clean up small numerical noise
209        let eigenvalue = if result.eigenvalue.abs() < 1e-8 {
210            0.0
211        } else {
212            result.eigenvalue
213        };
214
215        let mut eigvec = result.eigenvector;
216        for v in &mut eigvec {
217            if v.abs() < 1e-8 {
218                *v = 0.0;
219            }
220        }
221
222        // Normalize sign: first nonzero element should be positive
223        if let Some(first_nonzero) = eigvec.iter().find(|&&v| v != 0.0) {
224            if *first_nonzero < 0.0 {
225                for v in &mut eigvec {
226                    *v = -*v;
227                }
228            }
229        }
230
231        eigenvalues_out.push(eigenvalue);
232
233        if eigenvalue <= 0.0 {
234            continue;
235        }
236
237        // Count vertices in each side of the split
238        let neg_count = eigvec.iter().filter(|&&v| v < 0.0).count();
239        if neg_count == 0 || neg_count == size {
240            continue;
241        }
242
243        // Verify that the split actually increases modularity
244        // by computing x^T B x for the sign vector
245        let mut sign_vec = vec![0.0_f64; size];
246        for (i, &v) in eigvec.iter().enumerate() {
247            sign_vec[i] = if v < 0.0 { -1.0 } else { 1.0 };
248        }
249        let mut bx = vec![0.0_f64; size];
250        matvec(&sign_vec, &mut bx);
251        let mod_increase: f64 = sign_vec.iter().zip(bx.iter()).map(|(s, b)| s * b).sum();
252        if mod_increase <= 1e-8 {
253            continue;
254        }
255
256        // Perform the split
257        let new_comm = communities;
258        communities += 1;
259
260        for (j, &v) in eigvec.iter().enumerate() {
261            if v < 0.0 {
262                membership[idx[j]] = new_comm as u32;
263            }
264        }
265
266        merges_out.push((comm, new_comm as u32));
267
268        // Queue sub-communities for further splitting
269        let pos_count = size - neg_count;
270        if neg_count > 1 {
271            to_split.push_back(new_comm as u32);
272        }
273        if pos_count > 1 {
274            to_split.push_back(comm);
275        }
276    }
277
278    // Compute final modularity
279    let mod_val = compute_modularity(graph, weights, &membership, &deg_or_strength, two_m);
280
281    Ok(LeadingEigenvectorResult {
282        membership,
283        eigenvalues: eigenvalues_out,
284        merges: merges_out,
285        modularity: mod_val,
286    })
287}
288
289/// Weighted leading eigenvector community detection (convenience wrapper).
290///
291/// Equivalent to `leading_eigenvector(graph, Some(weights), steps)`.
292///
293/// # Examples
294///
295/// ```
296/// use rust_igraph::{Graph, leading_eigenvector_weighted};
297///
298/// let mut g = Graph::with_vertices(4);
299/// g.add_edge(0, 1).unwrap();
300/// g.add_edge(1, 2).unwrap();
301/// g.add_edge(2, 3).unwrap();
302/// g.add_edge(0, 3).unwrap();
303/// let w = vec![1.0; 4];
304/// let r = leading_eigenvector_weighted(&g, &w, None).unwrap();
305/// assert_eq!(r.membership.len(), 4);
306/// ```
307pub fn leading_eigenvector_weighted(
308    graph: &Graph,
309    weights: &[f64],
310    steps: Option<u32>,
311) -> IgraphResult<LeadingEigenvectorResult> {
312    leading_eigenvector(graph, Some(weights), steps)
313}
314
315// ─── Internal helpers ────────────────────────────────────────────
316
317type AdjList = Vec<Vec<(usize, usize)>>; // adj[v] = [(neighbor, edge_id), ...]
318
319fn build_adjacency(graph: &Graph) -> AdjList {
320    let n = graph.vcount() as usize;
321    let mut adj: AdjList = vec![Vec::new(); n];
322    for eid in 0..graph.ecount() {
323        #[allow(clippy::cast_possible_truncation)]
324        let eid32 = eid as u32;
325        let Ok(s) = graph.edge_source(eid32) else {
326            continue;
327        };
328        let Ok(t) = graph.edge_target(eid32) else {
329            continue;
330        };
331        let s = s as usize;
332        let t = t as usize;
333        adj[s].push((t, eid));
334        if s != t {
335            adj[t].push((s, eid));
336        }
337    }
338    adj
339}
340
341#[allow(clippy::cast_precision_loss)]
342fn compute_degrees(graph: &Graph, weights: Option<&[f64]>, adj: &AdjList) -> (Vec<f64>, f64) {
343    let n = graph.vcount() as usize;
344    let mut deg = vec![0.0_f64; n];
345    let mut total = 0.0_f64;
346
347    match weights {
348        None => {
349            for v in 0..n {
350                let d = adj[v].len() as f64;
351                deg[v] = d;
352                total += d;
353            }
354        }
355        Some(w) => {
356            for v in 0..n {
357                let mut s = 0.0_f64;
358                for &(_, eid) in &adj[v] {
359                    s += w[eid];
360                }
361                deg[v] = s;
362                total += s;
363            }
364        }
365    }
366
367    (deg, total)
368}
369
370/// Compute `y = B_comm * x` where `B_comm` is the modularity matrix
371/// restricted to community `comm`.
372///
373/// `B_ij = A_ij - k_i * k_j / (2m)`, restricted to vertices in `comm`,
374/// with diagonal correction to make `B_comm` have zero row sums within
375/// the community (the "generalized modularity matrix" from Newman 2006).
376#[allow(clippy::too_many_arguments)]
377fn modularity_matvec(
378    _graph: &Graph,
379    weights: Option<&[f64]>,
380    adj: &AdjList,
381    deg: &[f64],
382    two_m: f64,
383    membership: &[u32],
384    comm: u32,
385    idx: &[usize],
386    idx2: &[usize],
387    x: &[f64],
388    y: &mut [f64],
389) {
390    let size = idx.len();
391    let inv_2m = 1.0 / two_m;
392    let mut tmp = vec![0.0_f64; size];
393
394    // Step 1: Ax (adjacency restricted to community)
395    for j in 0..size {
396        let v = idx[j];
397        y[j] = 0.0;
398        tmp[j] = 0.0;
399        for &(nei, eid) in &adj[v] {
400            if membership[nei] == comm {
401                let w = match weights {
402                    Some(wt) => wt[eid],
403                    None => 1.0,
404                };
405                y[j] += x[idx2[nei]] * w;
406                tmp[j] += w;
407            }
408        }
409    }
410
411    // Step 2: k^T x / (2m)
412    let mut ktx = 0.0_f64;
413    let mut ktx2 = 0.0_f64;
414    for j in 0..size {
415        let v = idx[j];
416        ktx += x[j] * deg[v];
417        ktx2 += deg[v];
418    }
419    ktx *= inv_2m;
420    ktx2 *= inv_2m;
421
422    // Step 3: Bx = Ax - k * (k^T x) / (2m)
423    for j in 0..size {
424        let v = idx[j];
425        y[j] -= ktx * deg[v];
426        tmp[j] -= ktx2 * deg[v];
427    }
428
429    // Step 4: diagonal correction -d_ij * sum_l B_il
430    // (ensures B_comm has zero row sums within the community)
431    for j in 0..size {
432        y[j] -= tmp[j] * x[j];
433    }
434}
435
436fn compute_modularity(
437    graph: &Graph,
438    weights: Option<&[f64]>,
439    membership: &[u32],
440    deg: &[f64],
441    two_m: f64,
442) -> f64 {
443    if two_m <= 0.0 {
444        return 0.0;
445    }
446    let inv_2m = 1.0 / two_m;
447    let mut q = 0.0_f64;
448
449    for eid in 0..graph.ecount() {
450        #[allow(clippy::cast_possible_truncation)]
451        let eid32 = eid as u32;
452        let Ok(s) = graph.edge_source(eid32) else {
453            continue;
454        };
455        let Ok(t) = graph.edge_target(eid32) else {
456            continue;
457        };
458        let s = s as usize;
459        let t = t as usize;
460        if membership[s] == membership[t] {
461            let w = match weights {
462                Some(wt) => wt[eid],
463                None => 1.0,
464            };
465            if s == t {
466                q += w - deg[s] * deg[t] * inv_2m;
467            } else {
468                q += 2.0 * (w - deg[s] * deg[t] * inv_2m);
469            }
470        }
471    }
472
473    q * inv_2m
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn empty_graph() {
482        let g = Graph::with_vertices(0);
483        let r = leading_eigenvector(&g, None, None).unwrap();
484        assert!(r.membership.is_empty());
485    }
486
487    #[test]
488    fn single_vertex() {
489        let g = Graph::with_vertices(1);
490        let r = leading_eigenvector(&g, None, None).unwrap();
491        assert_eq!(r.membership, vec![0]);
492    }
493
494    #[test]
495    fn two_components() {
496        // Two disconnected edges
497        let mut g = Graph::with_vertices(4);
498        g.add_edge(0, 1).unwrap();
499        g.add_edge(2, 3).unwrap();
500        let r = leading_eigenvector(&g, None, None).unwrap();
501        assert_eq!(r.membership[0], r.membership[1]);
502        assert_eq!(r.membership[2], r.membership[3]);
503        assert_ne!(r.membership[0], r.membership[2]);
504    }
505
506    #[test]
507    fn barbell_finds_two_communities() {
508        let mut g = Graph::with_vertices(6);
509        // Triangle 1
510        g.add_edge(0, 1).unwrap();
511        g.add_edge(1, 2).unwrap();
512        g.add_edge(0, 2).unwrap();
513        // Triangle 2
514        g.add_edge(3, 4).unwrap();
515        g.add_edge(4, 5).unwrap();
516        g.add_edge(3, 5).unwrap();
517        // Bridge
518        g.add_edge(2, 3).unwrap();
519
520        let r = leading_eigenvector(&g, None, None).unwrap();
521        // Same community within each triangle
522        assert_eq!(r.membership[0], r.membership[1]);
523        assert_eq!(r.membership[0], r.membership[2]);
524        assert_eq!(r.membership[3], r.membership[4]);
525        assert_eq!(r.membership[3], r.membership[5]);
526        // Different communities across triangles
527        assert_ne!(r.membership[0], r.membership[3]);
528    }
529
530    #[test]
531    fn complete_graph_no_split() {
532        // K5: no meaningful split possible
533        let mut g = Graph::with_vertices(5);
534        for i in 0..5u32 {
535            for j in (i + 1)..5 {
536                g.add_edge(i, j).unwrap();
537            }
538        }
539        let r = leading_eigenvector(&g, None, None).unwrap();
540        // All vertices should be in the same community
541        let c = r.membership[0];
542        for &m in &r.membership {
543            assert_eq!(m, c, "K5 should not be split");
544        }
545    }
546
547    #[test]
548    fn weighted_barbell() {
549        let mut g = Graph::with_vertices(6);
550        // Triangle 1 with heavy edges
551        g.add_edge(0, 1).unwrap();
552        g.add_edge(1, 2).unwrap();
553        g.add_edge(0, 2).unwrap();
554        // Triangle 2 with heavy edges
555        g.add_edge(3, 4).unwrap();
556        g.add_edge(4, 5).unwrap();
557        g.add_edge(3, 5).unwrap();
558        // Weak bridge
559        g.add_edge(2, 3).unwrap();
560
561        let weights = vec![5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 0.1];
562        let r = leading_eigenvector(&g, Some(&weights), None).unwrap();
563        assert_eq!(r.membership[0], r.membership[1]);
564        assert_eq!(r.membership[3], r.membership[4]);
565        assert_ne!(r.membership[0], r.membership[3]);
566    }
567
568    #[test]
569    fn steps_limit() {
570        let mut g = Graph::with_vertices(6);
571        g.add_edge(0, 1).unwrap();
572        g.add_edge(1, 2).unwrap();
573        g.add_edge(0, 2).unwrap();
574        g.add_edge(3, 4).unwrap();
575        g.add_edge(4, 5).unwrap();
576        g.add_edge(3, 5).unwrap();
577        g.add_edge(2, 3).unwrap();
578
579        // With 0 steps, should get initial component assignment
580        let r = leading_eigenvector(&g, None, Some(0)).unwrap();
581        // All in one component initially
582        let c = r.membership[0];
583        for &m in &r.membership {
584            assert_eq!(m, c);
585        }
586    }
587
588    #[test]
589    fn modularity_is_positive_for_good_split() {
590        let mut g = Graph::with_vertices(6);
591        g.add_edge(0, 1).unwrap();
592        g.add_edge(1, 2).unwrap();
593        g.add_edge(0, 2).unwrap();
594        g.add_edge(3, 4).unwrap();
595        g.add_edge(4, 5).unwrap();
596        g.add_edge(3, 5).unwrap();
597        g.add_edge(2, 3).unwrap();
598
599        let r = leading_eigenvector(&g, None, None).unwrap();
600        assert!(
601            r.modularity > 0.0,
602            "modularity should be positive for barbell, got {}",
603            r.modularity
604        );
605    }
606}