Skip to main content

yo_graph/algo/
betweenness.rs

1//! How often each node sits in the middle of somebody else's shortest path.
2//!
3//! Brandes, "A faster algorithm for betweenness centrality", Journal of
4//! Mathematical Sociology 2001, sampled the way Brandes and Pich describe in
5//! "Centrality estimation in large networks", Int. J. Bifurcation and Chaos
6//! 2007.
7//!
8//! # What it measures, and why it is not PageRank
9//!
10//! [`super::pagerank()`] says a node is important if important nodes point at
11//! it. Betweenness says a node is important if traffic has to go through it. The
12//! two disagree in exactly the interesting place: the one badly connected node
13//! joining two otherwise separate halves of a network has almost no PageRank and
14//! the highest betweenness in the graph. That is the node whose failure splits
15//! the network, the account brokering between two communities, the router
16//! everything crosses.
17//!
18//! # How Brandes made it affordable
19//!
20//! Written out of the definition it is a sum over every pair of nodes, which
21//! means counting shortest paths between all of them and is cubic. Brandes'
22//! observation is that the whole sum can be accumulated one source at a time in
23//! the time of a single search: run a breadth first search from `s` counting how
24//! many shortest paths reach each node, then walk the search back out from the
25//! furthest node inwards accumulating what each node owes its predecessors. That
26//! turns the problem into one search per source, and nothing else.
27//!
28//! It is still one search per source, which on a graph with ten million nodes is
29//! ten million searches. Hence the sampling.
30//!
31//! # Why sampling is honest here
32//!
33//! Each source contributes its own independent share of the total, so running
34//! the accumulation from a random sample of sources and scaling by how much of
35//! the graph was sampled is an unbiased estimate of the real thing. Brandes and
36//! Pich also make the point that the sources have to be picked uniformly at
37//! random: sampling the highest degree nodes, which sounds smarter, is biased
38//! and can be much worse than sampling at random.
39//!
40//! The sources are drawn from [`yo_common::Rng`] on a fixed seed, so the
41//! estimate is an estimate but it is the same estimate every time.
42//!
43//! # Which way the edges point
44//!
45//! A shortest path follows edges the way they point, the same as [`super::bfs()`]
46//! and [`super::sssp()`]. A caller who wants the undirected reading should say so
47//! in the graph by linking both ways.
48//!
49//! ```
50//! use yo_graph::{Graph, NO_PROPS, Snapshot, algo};
51//!
52//! let mut g = Graph::new();
53//! // Two triangles that can only reach each other through node 3.
54//! for (a, b) in [(1u64, 2u64), (2, 1), (2, 3), (3, 2), (3, 4), (4, 3), (4, 5), (5, 4)] {
55//!     g.link(a, b, 1, NO_PROPS)?;
56//! }
57//!
58//! let s = Snapshot::of(&g);
59//! let c = algo::betweenness(&s);
60//! // Node 3 is on the path between both halves and nothing else is.
61//! assert_eq!(c.top(1)[0].0, s.dense(3).unwrap());
62//! # Ok::<(), yo_common::Error>(())
63//! ```
64
65use crate::Snapshot;
66use crate::algo::bfs::UNREACHED;
67use yo_common::Rng;
68
69/// How many sources [`betweenness`] runs from.
70///
71/// Brandes and Pich report that a few hundred sources put the ranking of the top
72/// nodes within a few percent of the exact answer on graphs of every size they
73/// tried, and that what the sample size has to grow with is the accuracy wanted
74/// rather than the size of the graph.
75pub const PIVOTS: u32 = 256;
76
77const SEED: u64 = 0xb173_eee0;
78
79/// How central each node is, and how it was worked out.
80#[derive(Debug, Clone)]
81pub struct Between {
82    of: Vec<f64>,
83    pivots: u32,
84    exact: bool,
85}
86
87impl Between {
88    /// One node's score.
89    ///
90    /// # Panics
91    ///
92    /// If `node` is not a node of the snapshot this was computed from.
93    #[must_use]
94    pub fn of(&self, node: u32) -> f64 {
95        self.of[node as usize]
96    }
97
98    /// Every node's score, in dense id order.
99    #[must_use]
100    pub fn scores(&self) -> &[f64] {
101        &self.of
102    }
103
104    /// How many sources it ran from.
105    #[must_use]
106    pub fn pivots(&self) -> u32 {
107        self.pivots
108    }
109
110    /// Whether every node was used as a source, which makes this the real
111    /// answer rather than an estimate of it.
112    #[must_use]
113    pub fn exact(&self) -> bool {
114        self.exact
115    }
116
117    /// The `n` most central nodes, highest first.
118    ///
119    /// The lower numbered node first when two scores match, so the answer does
120    /// not depend on the sort.
121    #[must_use]
122    pub fn top(&self, n: usize) -> Vec<(u32, f64)> {
123        let mut all: Vec<(u32, f64)> = self
124            .of
125            .iter()
126            .enumerate()
127            .map(|(node, score)| (node as u32, *score))
128            .collect();
129        all.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
130        all.truncate(n);
131        all
132    }
133}
134
135/// An estimate of every node's betweenness, from [`PIVOTS`] random sources.
136#[must_use]
137pub fn betweenness(g: &Snapshot) -> Between {
138    betweenness_with(g, PIVOTS)
139}
140
141/// The same, from a sample of the size asked for.
142///
143/// More sources is a better estimate and a proportionally longer wait, and
144/// asking for at least as many as there are nodes is the same as asking for
145/// [`betweenness_exact`].
146#[must_use]
147pub fn betweenness_with(g: &Snapshot, pivots: u32) -> Between {
148    let n = g.nodes();
149    if pivots >= n {
150        return betweenness_exact(g);
151    }
152    let mut from: Vec<u32> = (0..n).collect();
153    // Only the front of the shuffle is needed, so only the front is done.
154    let mut rng = Rng::new(SEED);
155    for at in 0..pivots as usize {
156        let take = at + (rng.next_u64() % (n as u64 - at as u64)) as usize;
157        from.swap(at, take);
158    }
159    from.truncate(pivots as usize);
160
161    let mut c = accumulate(g, &from);
162    // Each source stands for the ones that were not picked.
163    let scale = f64::from(n) / f64::from(pivots);
164    for score in &mut c.of {
165        *score *= scale;
166    }
167    c
168}
169
170/// Every node's betweenness, from every source, which is the real answer.
171///
172/// One breadth first search per node, so a graph of any size is a long wait.
173/// Here to check the estimate against and for graphs small enough that exact is
174/// affordable.
175#[must_use]
176pub fn betweenness_exact(g: &Snapshot) -> Between {
177    let all: Vec<u32> = (0..g.nodes()).collect();
178    let mut c = accumulate(g, &all);
179    c.exact = true;
180    c
181}
182
183/// Brandes' accumulation, run from each source in turn.
184fn accumulate(g: &Snapshot, from: &[u32]) -> Between {
185    let n = g.nodes() as usize;
186    let mut of = vec![0f64; n];
187    if n == 0 {
188        return Between {
189            of,
190            pivots: 0,
191            exact: false,
192        };
193    }
194
195    // How far each node is, how many shortest paths reach it, and what it owes.
196    // All three are cleared after each source through the visit order rather
197    // than by wiping the whole array, so a source that reaches a hundred nodes
198    // costs a hundred rather than the size of the graph.
199    let mut depth = vec![UNREACHED; n];
200    let mut paths = vec![0f64; n];
201    let mut owed = vec![0f64; n];
202    let mut order: Vec<u32> = Vec::new();
203
204    for src in from {
205        order.clear();
206        depth[*src as usize] = 0;
207        paths[*src as usize] = 1.0;
208
209        // Out from the source, counting shortest paths as it goes. A node one
210        // level further on gains every path that reached whoever found it.
211        let mut head = 0usize;
212        order.push(*src);
213        while head < order.len() {
214            let node = order[head];
215            head += 1;
216            let next = depth[node as usize] + 1;
217            for to in g.out(node) {
218                if depth[*to as usize] == UNREACHED {
219                    depth[*to as usize] = next;
220                    order.push(*to);
221                }
222                if depth[*to as usize] == next {
223                    paths[*to as usize] += paths[node as usize];
224                }
225            }
226        }
227
228        // Then back in, furthest first, which is the order the accumulation
229        // needs: a node cannot know what it owes until everything beyond it
230        // does. The predecessors are read off the incoming side rather than
231        // stored on the way out, which is what keeps this linear in memory.
232        for node in order.iter().rev() {
233            if depth[*node as usize] > 0 {
234                let share = (1.0 + owed[*node as usize]) / paths[*node as usize];
235                let back = depth[*node as usize] - 1;
236                for to in g.into_(*node) {
237                    if depth[*to as usize] == back {
238                        owed[*to as usize] += paths[*to as usize] * share;
239                    }
240                }
241            }
242            if node != src {
243                of[*node as usize] += owed[*node as usize];
244            }
245        }
246
247        for node in &order {
248            depth[*node as usize] = UNREACHED;
249            paths[*node as usize] = 0.0;
250            owed[*node as usize] = 0.0;
251        }
252    }
253
254    Between {
255        of,
256        pivots: from.len() as u32,
257        exact: false,
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::graph::NO_PROPS;
265    use crate::{Graph, Snapshot};
266    use yo_common::Rng;
267
268    fn linked(edges: &[(u64, u64)]) -> Graph {
269        let mut g = Graph::new();
270        for (from, to) in edges {
271            g.link(*from, *to, 1, NO_PROPS).expect("an edge");
272        }
273        g
274    }
275
276    /// Both ways round, which is how an undirected graph is said here.
277    fn undirected(edges: &[(u64, u64)]) -> Graph {
278        let mut both: Vec<(u64, u64)> = Vec::new();
279        for (a, b) in edges {
280            both.push((*a, *b));
281            both.push((*b, *a));
282        }
283        linked(&both)
284    }
285
286    /// Straight off the definition: for every pair, how many of the shortest
287    /// paths between them go through each node in the middle.
288    fn reference(g: &Snapshot) -> Vec<f64> {
289        let n = g.nodes() as usize;
290        // Shortest path counts and distances, from and to every node.
291        let count = |src: u32, back: bool| {
292            let mut far = vec![u32::MAX; n];
293            let mut paths = vec![0f64; n];
294            far[src as usize] = 0;
295            paths[src as usize] = 1.0;
296            let mut order = vec![src];
297            let mut head = 0;
298            while head < order.len() {
299                let node = order[head];
300                head += 1;
301                let next = far[node as usize] + 1;
302                let near = if back { g.into_(node) } else { g.out(node) };
303                for to in near {
304                    if far[*to as usize] == u32::MAX {
305                        far[*to as usize] = next;
306                        order.push(*to);
307                    }
308                    if far[*to as usize] == next {
309                        paths[*to as usize] += paths[node as usize];
310                    }
311                }
312            }
313            (far, paths)
314        };
315
316        let out: Vec<(Vec<u32>, Vec<f64>)> = (0..n as u32).map(|s| count(s, false)).collect();
317        let into: Vec<(Vec<u32>, Vec<f64>)> = (0..n as u32).map(|s| count(s, true)).collect();
318
319        let mut of = vec![0f64; n];
320        for (s, (far_s, count_s)) in out.iter().enumerate() {
321            for (t, (far_t, count_t)) in into.iter().enumerate() {
322                if s == t || far_s[t] == u32::MAX {
323                    continue;
324                }
325                let (far, all) = (far_s[t], count_s[t]);
326                for (v, of) in of.iter_mut().enumerate() {
327                    if v == s || v == t {
328                        continue;
329                    }
330                    let (there, back) = (far_s[v], far_t[v]);
331                    if there == u32::MAX || back == u32::MAX || there + back != far {
332                        continue;
333                    }
334                    *of += count_s[v] * count_t[v] / all;
335                }
336            }
337        }
338        of
339    }
340
341    #[test]
342    fn the_middle_of_a_chain() {
343        // 1 to 2 to 3, so only node 2 is ever in the middle, and it is in the
344        // middle of the one pair that has to cross it.
345        let s = Snapshot::of(&undirected(&[(1, 2), (2, 3)]));
346        let c = betweenness_exact(&s);
347        assert!((c.of(s.dense(2).expect("2")) - 2.0).abs() < 1e-9);
348        assert_eq!(c.of(s.dense(1).expect("1")), 0.0);
349        assert_eq!(c.of(s.dense(3).expect("3")), 0.0);
350        assert!(c.exact());
351    }
352
353    #[test]
354    fn the_bridge_between_two_halves() {
355        let mut edges = Vec::new();
356        for a in 0..5u64 {
357            for b in a + 1..5 {
358                edges.push((a, b));
359                edges.push((a + 10, b + 10));
360            }
361        }
362        edges.push((4, 10));
363        let s = Snapshot::of(&undirected(&edges));
364        let c = betweenness_exact(&s);
365        let top = c.top(2);
366        let ends = [s.dense(4).expect("4"), s.dense(10).expect("10")];
367        assert!(ends.contains(&top[0].0), "{top:?}");
368        assert!(ends.contains(&top[1].0), "{top:?}");
369    }
370
371    #[test]
372    fn a_clique_spreads_it_evenly() {
373        let mut edges = Vec::new();
374        for a in 0..6u64 {
375            for b in a + 1..6 {
376                edges.push((a, b));
377            }
378        }
379        let s = Snapshot::of(&undirected(&edges));
380        let c = betweenness_exact(&s);
381        // Everybody is next to everybody, so nobody is ever in the middle.
382        assert!(c.scores().iter().all(|score| score.abs() < 1e-9));
383    }
384
385    #[test]
386    fn it_agrees_with_the_definition() {
387        let mut rng = Rng::new(0xb17e);
388        for case in 0..40 {
389            let nodes = 2 + rng.next_u64() % 25;
390            let edges: Vec<(u64, u64)> = (0..nodes * 2)
391                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
392                .collect();
393            let s = Snapshot::of(&linked(&edges));
394            let (mine, theirs) = (betweenness_exact(&s), reference(&s));
395            for node in 0..s.nodes() {
396                let apart = (mine.of(node) - theirs[node as usize]).abs();
397                assert!(apart < 1e-9, "case {case}, node {node}, {apart} out");
398            }
399        }
400    }
401
402    /// The same, on graphs read both ways, because an undirected graph has
403    /// twice as many shortest paths to get wrong.
404    #[test]
405    fn it_agrees_with_the_definition_both_ways() {
406        let mut rng = Rng::new(0xb17f);
407        for case in 0..30 {
408            let nodes = 3 + rng.next_u64() % 20;
409            let edges: Vec<(u64, u64)> = (0..nodes)
410                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
411                .collect();
412            let s = Snapshot::of(&undirected(&edges));
413            let (mine, theirs) = (betweenness_exact(&s), reference(&s));
414            for node in 0..s.nodes() {
415                assert!(
416                    (mine.of(node) - theirs[node as usize]).abs() < 1e-9,
417                    "case {case}, node {node}"
418                );
419            }
420        }
421    }
422
423    /// The estimate has to put the same node at the top as the real answer on a
424    /// graph where one node obviously belongs there.
425    #[test]
426    fn the_estimate_finds_the_bridge() {
427        let mut edges = Vec::new();
428        for group in 0..2u64 {
429            for a in 0..30u64 {
430                for b in a + 1..30 {
431                    edges.push((group * 100 + a, group * 100 + b));
432                }
433            }
434        }
435        edges.push((29, 100));
436        let s = Snapshot::of(&undirected(&edges));
437        let sampled = betweenness_with(&s, 20);
438        let exact = betweenness_exact(&s);
439        assert!(!sampled.exact());
440        assert_eq!(sampled.pivots(), 20);
441
442        let ends = [s.dense(29).expect("29"), s.dense(100).expect("100")];
443        assert!(ends.contains(&sampled.top(1)[0].0));
444        assert!(ends.contains(&exact.top(1)[0].0));
445    }
446
447    /// And the estimate has to be near the real number, not just in the right
448    /// order. Sampling half the sources gets an individual node wrong by a
449    /// fifth of the largest score now and then, which is what an estimate is,
450    /// so what is checked is the error across the whole graph: on average a
451    /// small fraction of the largest score, and never wildly out.
452    #[test]
453    fn the_estimate_is_close() {
454        let mut rng = Rng::new(0xb180);
455        let nodes = 200u64;
456        let edges: Vec<(u64, u64)> = (0..nodes * 4)
457            .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
458            .collect();
459        let s = Snapshot::of(&undirected(&edges));
460        let exact = betweenness_exact(&s);
461        let sampled = betweenness_with(&s, 100);
462        let most = exact.top(1)[0].1;
463
464        let apart: Vec<f64> = (0..s.nodes())
465            .map(|node| (sampled.of(node) - exact.of(node)).abs())
466            .collect();
467        let mean = apart.iter().sum::<f64>() / f64::from(s.nodes());
468        let worst = apart.iter().copied().fold(0f64, f64::max);
469        assert!(mean < most / 20.0, "{mean} on average out of {most}");
470        assert!(worst < most / 3.0, "{worst} at worst out of {most}");
471    }
472
473    #[test]
474    fn asking_for_everybody_is_the_exact_answer() {
475        let s = Snapshot::of(&undirected(&[(1, 2), (2, 3), (3, 4)]));
476        let all = betweenness_with(&s, 99);
477        assert!(all.exact());
478        assert_eq!(all.scores(), betweenness_exact(&s).scores());
479    }
480
481    #[test]
482    fn nothing_at_all() {
483        let c = betweenness(&Snapshot::default());
484        assert!(c.scores().is_empty());
485        assert!(c.top(3).is_empty());
486        assert_eq!(c.pivots(), 0);
487    }
488
489    #[test]
490    fn a_graph_with_no_edges() {
491        let mut g = Graph::new();
492        for id in 0..4u64 {
493            g.add_node(id).expect("a node");
494        }
495        let c = betweenness(&Snapshot::of(&g));
496        assert!(c.scores().iter().all(|score| *score == 0.0));
497    }
498
499    /// Direction is the whole answer here, unlike in the community algorithms.
500    #[test]
501    fn one_way_edges_are_read_one_way() {
502        // A path that only runs one way, so 2 is in the middle of exactly one
503        // ordered pair rather than two.
504        let s = Snapshot::of(&linked(&[(1, 2), (2, 3)]));
505        let c = betweenness_exact(&s);
506        assert!((c.of(s.dense(2).expect("2")) - 1.0).abs() < 1e-9);
507    }
508
509    /// Two shortest paths through different nodes split the credit.
510    #[test]
511    fn a_tie_is_shared() {
512        // 1 reaches 4 through either 2 or 3, in two hops either way.
513        let s = Snapshot::of(&linked(&[(1, 2), (1, 3), (2, 4), (3, 4)]));
514        let c = betweenness_exact(&s);
515        assert!((c.of(s.dense(2).expect("2")) - 0.5).abs() < 1e-9);
516        assert!((c.of(s.dense(3).expect("3")) - 0.5).abs() < 1e-9);
517    }
518
519    #[test]
520    fn two_runs_agree() {
521        let mut rng = Rng::new(0xb181);
522        let edges: Vec<(u64, u64)> = (0..200)
523            .map(|_| (rng.next_u64() % 60, rng.next_u64() % 60))
524            .collect();
525        let s = Snapshot::of(&undirected(&edges));
526        assert_eq!(
527            betweenness_with(&s, 10).scores(),
528            betweenness_with(&s, 10).scores()
529        );
530    }
531}