rust_igraph/algorithms/properties/eigenvector.rs
1//! Eigenvector centrality (ALGO-PR-012).
2//!
3//! Counterpart of `igraph_eigenvector_centrality()` from
4//! `references/igraph/src/centrality/eigenvector.c`. The eigenvector
5//! centrality of `v` is its component in the dominant eigenvector of
6//! the adjacency matrix (largest eigenvalue, real and positive by
7//! Perron-Frobenius for connected non-bipartite graphs).
8//!
9//! Implemented via shifted power iteration on `(A + I)`:
10//! `x_new[v] = x_old[v] + Σ_{u ~ v} x_old[u]`, then normalize so
11//! `max(x) == 1`. The shift doesn't change eigenvectors but breaks the
12//! `±λ` symmetry of bipartite adjacency matrices, ensuring convergence
13//! to the *positive* dominant eigenvector. Iterate until L1 change <
14//! `1e-10` or 1000 iterations. Matches `python-igraph 0.11`'s
15//! normalization convention (max = 1).
16//!
17//! Phase-1 minimal slice: undirected, unweighted. Directed mode (with
18//! either `IGRAPH_OUT` or `IGRAPH_IN` semantics) and weighted variants
19//! ship later.
20
21use crate::core::{Graph, IgraphError, IgraphResult};
22
23// Tighter than the L1-convergence default we use elsewhere because
24// rustdoc/conformance compare bit-precise vs python-igraph's ARPACK.
25const DEFAULT_EPS: f64 = 1e-14;
26const DEFAULT_MAX_ITER: usize = 5000;
27
28/// Eigenvector centrality scores normalized so `max == 1`.
29///
30/// Returns `Vec<f64>` of length `vcount`. Empty graph returns empty.
31/// Directed graphs return [`crate::IgraphError::Unsupported`] for now —
32/// directed eigenvector centrality requires careful eigenvector choice
33/// (in-edges vs out-edges) and ships later.
34///
35/// Counterpart of
36/// `igraph_eigenvector_centrality(_, _, NULL_eval, /*directed=*/false,
37/// /*scale=*/true, NULL_weights, NULL_options)`.
38///
39/// # Examples
40///
41/// ```
42/// use rust_igraph::{Graph, eigenvector_centrality};
43///
44/// // Triangle: every vertex has identical centrality 1.0.
45/// let mut g = Graph::with_vertices(3);
46/// g.add_edge(0, 1).unwrap();
47/// g.add_edge(1, 2).unwrap();
48/// g.add_edge(2, 0).unwrap();
49/// let ec = eigenvector_centrality(&g).unwrap();
50/// assert!((ec[0] - 1.0).abs() < 1e-9);
51/// assert!((ec[1] - 1.0).abs() < 1e-9);
52/// assert!((ec[2] - 1.0).abs() < 1e-9);
53/// ```
54pub fn eigenvector_centrality(graph: &Graph) -> IgraphResult<Vec<f64>> {
55 if graph.is_directed() {
56 return Err(IgraphError::Unsupported(
57 "directed eigenvector_centrality is PR-012b; not yet ported",
58 ));
59 }
60 let n = graph.vcount();
61 let n_us = n as usize;
62 if n == 0 {
63 return Ok(Vec::new());
64 }
65 if n == 1 {
66 // Single vertex: trivial dominant-eigenvector convention is 1.
67 return Ok(vec![1.0]);
68 }
69
70 // Pre-cache neighbour lists.
71 let mut adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
72 for v in 0..n {
73 adj.push(graph.neighbors(v)?);
74 }
75
76 // Initial uniform distribution. Some entry must be nonzero or the
77 // iteration is stuck at zero — uniform 1.0 works.
78 let mut x = vec![1.0_f64; n_us];
79 let mut x_new = vec![0.0_f64; n_us];
80
81 for _ in 0..DEFAULT_MAX_ITER {
82 // x_new = (A + I) * x to break ±λ bipartite symmetry.
83 for v in 0..n_us {
84 let mut sum = x[v];
85 for &u in &adj[v] {
86 sum += x[u as usize];
87 }
88 x_new[v] = sum;
89 }
90
91 // Normalize so max(|x_new|) = 1. If max is zero (graph has
92 // isolated vertices and we started with all-zero somehow),
93 // skip normalization to avoid /0.
94 let max = x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
95 if max > 0.0 {
96 for slot in &mut x_new {
97 *slot /= max;
98 }
99 }
100
101 // L1 convergence check.
102 let mut diff = 0.0_f64;
103 for v in 0..n_us {
104 diff += (x_new[v] - x[v]).abs();
105 }
106 std::mem::swap(&mut x, &mut x_new);
107 if diff < DEFAULT_EPS {
108 break;
109 }
110 }
111
112 Ok(x)
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn close(actual: &[f64], expected: &[f64], tol: f64) {
120 assert_eq!(actual.len(), expected.len(), "length mismatch");
121 for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
122 assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
123 }
124 }
125
126 #[test]
127 fn empty_graph_yields_empty() {
128 let g = Graph::with_vertices(0);
129 assert!(eigenvector_centrality(&g).unwrap().is_empty());
130 }
131
132 #[test]
133 fn singleton_yields_one() {
134 let g = Graph::with_vertices(1);
135 assert_eq!(eigenvector_centrality(&g).unwrap(), vec![1.0]);
136 }
137
138 #[test]
139 fn isolated_vertices_yield_uniform_one() {
140 // No edges → (A + I) = I → eigenvector is uniform; after max-1
141 // normalization, [1, 1, ..., 1]. Matches python-igraph 0.11.
142 let g = Graph::with_vertices(3);
143 let ec = eigenvector_centrality(&g).unwrap();
144 close(&ec, &[1.0, 1.0, 1.0], 1e-9);
145 }
146
147 #[test]
148 fn triangle_uniform_one() {
149 let mut g = Graph::with_vertices(3);
150 g.add_edge(0, 1).unwrap();
151 g.add_edge(1, 2).unwrap();
152 g.add_edge(2, 0).unwrap();
153 let ec = eigenvector_centrality(&g).unwrap();
154 close(&ec, &[1.0, 1.0, 1.0], 1e-9);
155 }
156
157 #[test]
158 fn star_4_centre_one_leaves_inverse_sqrt_three() {
159 // 4-star: dominant eigenvalue is sqrt(3); ratio centre:leaf = sqrt(3):1.
160 // After max-1 normalization: centre = 1, leaves = 1/sqrt(3).
161 let mut g = Graph::with_vertices(4);
162 for v in 1..4 {
163 g.add_edge(0, v).unwrap();
164 }
165 let ec = eigenvector_centrality(&g).unwrap();
166 let inv_sqrt3 = 1.0 / 3f64.sqrt();
167 close(&ec, &[1.0, inv_sqrt3, inv_sqrt3, inv_sqrt3], 1e-9);
168 }
169
170 #[test]
171 fn k4_uniform_one() {
172 // K_n: eigenvector is uniform (every vertex has the same role).
173 let mut g = Graph::with_vertices(4);
174 for u in 0..4u32 {
175 for v in (u + 1)..4 {
176 g.add_edge(u, v).unwrap();
177 }
178 }
179 let ec = eigenvector_centrality(&g).unwrap();
180 close(&ec, &[1.0, 1.0, 1.0, 1.0], 1e-9);
181 }
182
183 #[test]
184 fn directed_graph_returns_unsupported() {
185 let mut g = Graph::new(3, true).unwrap();
186 g.add_edge(0, 1).unwrap();
187 assert!(eigenvector_centrality(&g).is_err());
188 }
189}