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 s = graph.edge_source(eid32).unwrap() as usize;
326        let t = graph.edge_target(eid32).unwrap() as usize;
327        adj[s].push((t, eid));
328        if s != t {
329            adj[t].push((s, eid));
330        }
331    }
332    adj
333}
334
335#[allow(clippy::cast_precision_loss)]
336fn compute_degrees(graph: &Graph, weights: Option<&[f64]>, adj: &AdjList) -> (Vec<f64>, f64) {
337    let n = graph.vcount() as usize;
338    let mut deg = vec![0.0_f64; n];
339    let mut total = 0.0_f64;
340
341    match weights {
342        None => {
343            for v in 0..n {
344                let d = adj[v].len() as f64;
345                deg[v] = d;
346                total += d;
347            }
348        }
349        Some(w) => {
350            for v in 0..n {
351                let mut s = 0.0_f64;
352                for &(_, eid) in &adj[v] {
353                    s += w[eid];
354                }
355                deg[v] = s;
356                total += s;
357            }
358        }
359    }
360
361    (deg, total)
362}
363
364/// Compute `y = B_comm * x` where `B_comm` is the modularity matrix
365/// restricted to community `comm`.
366///
367/// `B_ij = A_ij - k_i * k_j / (2m)`, restricted to vertices in `comm`,
368/// with diagonal correction to make `B_comm` have zero row sums within
369/// the community (the "generalized modularity matrix" from Newman 2006).
370#[allow(clippy::too_many_arguments)]
371fn modularity_matvec(
372    _graph: &Graph,
373    weights: Option<&[f64]>,
374    adj: &AdjList,
375    deg: &[f64],
376    two_m: f64,
377    membership: &[u32],
378    comm: u32,
379    idx: &[usize],
380    idx2: &[usize],
381    x: &[f64],
382    y: &mut [f64],
383) {
384    let size = idx.len();
385    let inv_2m = 1.0 / two_m;
386    let mut tmp = vec![0.0_f64; size];
387
388    // Step 1: Ax (adjacency restricted to community)
389    for j in 0..size {
390        let v = idx[j];
391        y[j] = 0.0;
392        tmp[j] = 0.0;
393        for &(nei, eid) in &adj[v] {
394            if membership[nei] == comm {
395                let w = match weights {
396                    Some(wt) => wt[eid],
397                    None => 1.0,
398                };
399                y[j] += x[idx2[nei]] * w;
400                tmp[j] += w;
401            }
402        }
403    }
404
405    // Step 2: k^T x / (2m)
406    let mut ktx = 0.0_f64;
407    let mut ktx2 = 0.0_f64;
408    for j in 0..size {
409        let v = idx[j];
410        ktx += x[j] * deg[v];
411        ktx2 += deg[v];
412    }
413    ktx *= inv_2m;
414    ktx2 *= inv_2m;
415
416    // Step 3: Bx = Ax - k * (k^T x) / (2m)
417    for j in 0..size {
418        let v = idx[j];
419        y[j] -= ktx * deg[v];
420        tmp[j] -= ktx2 * deg[v];
421    }
422
423    // Step 4: diagonal correction -d_ij * sum_l B_il
424    // (ensures B_comm has zero row sums within the community)
425    for j in 0..size {
426        y[j] -= tmp[j] * x[j];
427    }
428}
429
430fn compute_modularity(
431    graph: &Graph,
432    weights: Option<&[f64]>,
433    membership: &[u32],
434    deg: &[f64],
435    two_m: f64,
436) -> f64 {
437    if two_m <= 0.0 {
438        return 0.0;
439    }
440    let inv_2m = 1.0 / two_m;
441    let mut q = 0.0_f64;
442
443    for eid in 0..graph.ecount() {
444        #[allow(clippy::cast_possible_truncation)]
445        let eid32 = eid as u32;
446        let s = graph.edge_source(eid32).unwrap() as usize;
447        let t = graph.edge_target(eid32).unwrap() as usize;
448        if membership[s] == membership[t] {
449            let w = match weights {
450                Some(wt) => wt[eid],
451                None => 1.0,
452            };
453            if s == t {
454                q += w - deg[s] * deg[t] * inv_2m;
455            } else {
456                q += 2.0 * (w - deg[s] * deg[t] * inv_2m);
457            }
458        }
459    }
460
461    q * inv_2m
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn empty_graph() {
470        let g = Graph::with_vertices(0);
471        let r = leading_eigenvector(&g, None, None).unwrap();
472        assert!(r.membership.is_empty());
473    }
474
475    #[test]
476    fn single_vertex() {
477        let g = Graph::with_vertices(1);
478        let r = leading_eigenvector(&g, None, None).unwrap();
479        assert_eq!(r.membership, vec![0]);
480    }
481
482    #[test]
483    fn two_components() {
484        // Two disconnected edges
485        let mut g = Graph::with_vertices(4);
486        g.add_edge(0, 1).unwrap();
487        g.add_edge(2, 3).unwrap();
488        let r = leading_eigenvector(&g, None, None).unwrap();
489        assert_eq!(r.membership[0], r.membership[1]);
490        assert_eq!(r.membership[2], r.membership[3]);
491        assert_ne!(r.membership[0], r.membership[2]);
492    }
493
494    #[test]
495    fn barbell_finds_two_communities() {
496        let mut g = Graph::with_vertices(6);
497        // Triangle 1
498        g.add_edge(0, 1).unwrap();
499        g.add_edge(1, 2).unwrap();
500        g.add_edge(0, 2).unwrap();
501        // Triangle 2
502        g.add_edge(3, 4).unwrap();
503        g.add_edge(4, 5).unwrap();
504        g.add_edge(3, 5).unwrap();
505        // Bridge
506        g.add_edge(2, 3).unwrap();
507
508        let r = leading_eigenvector(&g, None, None).unwrap();
509        // Same community within each triangle
510        assert_eq!(r.membership[0], r.membership[1]);
511        assert_eq!(r.membership[0], r.membership[2]);
512        assert_eq!(r.membership[3], r.membership[4]);
513        assert_eq!(r.membership[3], r.membership[5]);
514        // Different communities across triangles
515        assert_ne!(r.membership[0], r.membership[3]);
516    }
517
518    #[test]
519    fn complete_graph_no_split() {
520        // K5: no meaningful split possible
521        let mut g = Graph::with_vertices(5);
522        for i in 0..5u32 {
523            for j in (i + 1)..5 {
524                g.add_edge(i, j).unwrap();
525            }
526        }
527        let r = leading_eigenvector(&g, None, None).unwrap();
528        // All vertices should be in the same community
529        let c = r.membership[0];
530        for &m in &r.membership {
531            assert_eq!(m, c, "K5 should not be split");
532        }
533    }
534
535    #[test]
536    fn weighted_barbell() {
537        let mut g = Graph::with_vertices(6);
538        // Triangle 1 with heavy edges
539        g.add_edge(0, 1).unwrap();
540        g.add_edge(1, 2).unwrap();
541        g.add_edge(0, 2).unwrap();
542        // Triangle 2 with heavy edges
543        g.add_edge(3, 4).unwrap();
544        g.add_edge(4, 5).unwrap();
545        g.add_edge(3, 5).unwrap();
546        // Weak bridge
547        g.add_edge(2, 3).unwrap();
548
549        let weights = vec![5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 0.1];
550        let r = leading_eigenvector(&g, Some(&weights), None).unwrap();
551        assert_eq!(r.membership[0], r.membership[1]);
552        assert_eq!(r.membership[3], r.membership[4]);
553        assert_ne!(r.membership[0], r.membership[3]);
554    }
555
556    #[test]
557    fn steps_limit() {
558        let mut g = Graph::with_vertices(6);
559        g.add_edge(0, 1).unwrap();
560        g.add_edge(1, 2).unwrap();
561        g.add_edge(0, 2).unwrap();
562        g.add_edge(3, 4).unwrap();
563        g.add_edge(4, 5).unwrap();
564        g.add_edge(3, 5).unwrap();
565        g.add_edge(2, 3).unwrap();
566
567        // With 0 steps, should get initial component assignment
568        let r = leading_eigenvector(&g, None, Some(0)).unwrap();
569        // All in one component initially
570        let c = r.membership[0];
571        for &m in &r.membership {
572            assert_eq!(m, c);
573        }
574    }
575
576    #[test]
577    fn modularity_is_positive_for_good_split() {
578        let mut g = Graph::with_vertices(6);
579        g.add_edge(0, 1).unwrap();
580        g.add_edge(1, 2).unwrap();
581        g.add_edge(0, 2).unwrap();
582        g.add_edge(3, 4).unwrap();
583        g.add_edge(4, 5).unwrap();
584        g.add_edge(3, 5).unwrap();
585        g.add_edge(2, 3).unwrap();
586
587        let r = leading_eigenvector(&g, None, None).unwrap();
588        assert!(
589            r.modularity > 0.0,
590            "modularity should be positive for barbell, got {}",
591            r.modularity
592        );
593    }
594}