Skip to main content

graphops/
eigenvector.rs

1//! Eigenvector centrality via power iteration.
2//!
3//! Measures node importance based on the principle that connections to
4//! high-scoring nodes contribute more. Related to PageRank but without
5//! teleportation or damping.
6//!
7//! The dominant eigenvector of the adjacency matrix gives the centrality
8//! scores. For undirected graphs, this is well-defined (Perron-Frobenius).
9
10use crate::graph::GraphRef;
11
12/// Convergence details for eigenvector centrality computation.
13#[derive(Debug, Clone)]
14pub struct EigenvectorRun {
15    /// Centrality scores, L2-normalized.
16    pub scores: Vec<f64>,
17    /// Actual iterations performed.
18    pub iterations: usize,
19    /// Final L1 residual between successive iterations.
20    pub diff_l1: f64,
21    /// Whether the algorithm converged within tolerance.
22    pub converged: bool,
23}
24
25/// Compute eigenvector centrality with default parameters (max_iter=100, tol=1e-6).
26pub fn eigenvector_centrality<G: GraphRef>(graph: &G) -> Vec<f64> {
27    eigenvector_centrality_run(graph, 100, 1e-6).scores
28}
29
30/// Compute eigenvector centrality with full convergence reporting.
31///
32/// Scores are L2-normalized (unit vector). Isolated nodes get score 0.
33///
34/// ```
35/// use graphops::eigenvector::eigenvector_centrality_run;
36/// use graphops::GraphRef;
37///
38/// struct G(Vec<Vec<usize>>);
39/// impl GraphRef for G {
40///     fn node_count(&self) -> usize { self.0.len() }
41///     fn neighbors_ref(&self, n: usize) -> &[usize] { &self.0[n] }
42/// }
43///
44/// let g = G(vec![vec![1, 2], vec![0, 2], vec![0, 1]]);
45/// let run = eigenvector_centrality_run(&g, 100, 1e-6);
46/// assert!(run.converged);
47/// assert!((run.scores[0] - run.scores[1]).abs() < 1e-6); // symmetric graph
48/// ```
49pub fn eigenvector_centrality_run<G: GraphRef>(
50    graph: &G,
51    max_iterations: usize,
52    tolerance: f64,
53) -> EigenvectorRun {
54    let n = graph.node_count();
55    if n == 0 {
56        return EigenvectorRun {
57            scores: vec![],
58            iterations: 0,
59            diff_l1: 0.0,
60            converged: true,
61        };
62    }
63
64    // Initialize uniformly.
65    let init = 1.0 / (n as f64).sqrt();
66    let mut scores = vec![init; n];
67    let mut new_scores = vec![0.0; n];
68    let mut diff_l1 = 0.0;
69    let mut iterations = 0;
70
71    for iter in 0..max_iterations {
72        iterations = iter + 1;
73
74        // Multiply by (A + I): new[v] = scores[v] + sum(scores[u]) for u in neighbors(v).
75        // Adding the identity breaks bipartite oscillation while preserving eigenvector order.
76        for v in 0..n {
77            let mut sum = scores[v]; // self-loop (identity contribution)
78            for &u in graph.neighbors_ref(v) {
79                if u < n {
80                    sum += scores[u];
81                }
82            }
83            new_scores[v] = sum;
84        }
85
86        // L2-normalize with positive sign convention (largest component positive).
87        let norm: f64 = new_scores.iter().map(|x| x * x).sum::<f64>().sqrt();
88        if norm > 0.0 {
89            // Sign convention: ensure the vector has a positive sum.
90            let sign = if new_scores.iter().sum::<f64>() >= 0.0 {
91                1.0
92            } else {
93                -1.0
94            };
95            for x in &mut new_scores {
96                *x = *x * sign / norm;
97            }
98        }
99
100        // Compute L1 diff.
101        diff_l1 = scores
102            .iter()
103            .zip(new_scores.iter())
104            .map(|(a, b)| (a - b).abs())
105            .sum();
106
107        std::mem::swap(&mut scores, &mut new_scores);
108
109        if diff_l1 < tolerance {
110            return EigenvectorRun {
111                scores,
112                iterations,
113                diff_l1,
114                converged: true,
115            };
116        }
117    }
118
119    EigenvectorRun {
120        scores,
121        iterations,
122        diff_l1,
123        converged: false,
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::graph::GraphRef;
131
132    struct VecGraph {
133        adj: Vec<Vec<usize>>,
134    }
135
136    impl GraphRef for VecGraph {
137        fn node_count(&self) -> usize {
138            self.adj.len()
139        }
140        fn neighbors_ref(&self, node: usize) -> &[usize] {
141            &self.adj[node]
142        }
143    }
144
145    #[test]
146    fn triangle_all_equal() {
147        let g = VecGraph {
148            adj: vec![vec![1, 2], vec![0, 2], vec![0, 1]],
149        };
150        let run = eigenvector_centrality_run(&g, 100, 1e-8);
151        assert!(run.converged);
152        let expected = 1.0 / 3.0_f64.sqrt();
153        for &s in &run.scores {
154            assert!((s - expected).abs() < 1e-6, "score {s} != {expected}");
155        }
156    }
157
158    #[test]
159    fn star_center_highest() {
160        // Star: node 0 connected to 1,2,3,4
161        let g = VecGraph {
162            adj: vec![vec![1, 2, 3, 4], vec![0], vec![0], vec![0], vec![0]],
163        };
164        let scores = eigenvector_centrality(&g);
165        assert!(scores[0] > scores[1]);
166        assert!((scores[1] - scores[2]).abs() < 1e-6);
167    }
168
169    #[test]
170    fn empty_graph() {
171        let g = VecGraph { adj: vec![] };
172        let run = eigenvector_centrality_run(&g, 100, 1e-6);
173        assert!(run.scores.is_empty());
174        assert!(run.converged);
175    }
176
177    #[test]
178    fn isolated_nodes() {
179        let g = VecGraph {
180            adj: vec![vec![], vec![], vec![]],
181        };
182        let scores = eigenvector_centrality(&g);
183        // All zeros after normalization of zero vector.
184        for &s in &scores {
185            assert!(s.abs() < 1e-10 || (s - 1.0 / 3.0_f64.sqrt()).abs() < 1e-10);
186        }
187    }
188
189    #[test]
190    fn l2_normalized() {
191        let g = VecGraph {
192            adj: vec![vec![1, 2, 3], vec![0, 2], vec![0, 1, 3], vec![0, 2]],
193        };
194        let scores = eigenvector_centrality(&g);
195        let l2: f64 = scores.iter().map(|x| x * x).sum::<f64>().sqrt();
196        assert!((l2 - 1.0).abs() < 1e-6, "L2 norm = {l2}");
197    }
198}