Skip to main content

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}