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        // Fewer and smaller cases under Miri. Every edge here goes in through
389        // `Graph`, which puts a document for each end and one for the edge, and
390        // that is the most expensive thing in this crate to interpret. What is
391        // being checked is agreement with the definition on a graph nobody
392        // chose, and a handful of small ones still covers the ties, the
393        // unreachable pairs and the self loops that are what goes wrong.
394        let (cases, spread) = if cfg!(miri) { (3, 6) } else { (40, 25) };
395        for case in 0..cases {
396            let nodes = 2 + rng.next_u64() % spread;
397            let edges: Vec<(u64, u64)> = (0..nodes * 2)
398                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
399                .collect();
400            let s = Snapshot::of(&linked(&edges));
401            let (mine, theirs) = (betweenness_exact(&s), reference(&s));
402            for node in 0..s.nodes() {
403                let apart = (mine.of(node) - theirs[node as usize]).abs();
404                assert!(apart < 1e-9, "case {case}, node {node}, {apart} out");
405            }
406        }
407    }
408
409    /// The same, on graphs read both ways, because an undirected graph has
410    /// twice as many shortest paths to get wrong.
411    #[test]
412    fn it_agrees_with_the_definition_both_ways() {
413        let mut rng = Rng::new(0xb17f);
414        let (cases, spread) = if cfg!(miri) { (3, 5) } else { (30, 20) };
415        for case in 0..cases {
416            let nodes = 3 + rng.next_u64() % spread;
417            let edges: Vec<(u64, u64)> = (0..nodes)
418                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
419                .collect();
420            let s = Snapshot::of(&undirected(&edges));
421            let (mine, theirs) = (betweenness_exact(&s), reference(&s));
422            for node in 0..s.nodes() {
423                assert!(
424                    (mine.of(node) - theirs[node as usize]).abs() < 1e-9,
425                    "case {case}, node {node}"
426                );
427            }
428        }
429    }
430
431    /// The estimate has to put the same node at the top as the real answer on a
432    /// graph where one node obviously belongs there.
433    #[test]
434    fn the_estimate_finds_the_bridge() {
435        // Two cliques and one edge between them. Smaller cliques and fewer
436        // pivots under Miri, because the graph the claim asks for is two dense
437        // halves joined at one point rather than two halves of any given size.
438        // The pivot count has to stay under the node count or the estimate
439        // would be the exact answer and the assert below would be checking
440        // nothing, which is why it is cut alongside the cliques.
441        let (side, pivots) = if cfg!(miri) { (6u64, 5) } else { (30, 20) };
442        let mut edges = Vec::new();
443        for group in 0..2u64 {
444            for a in 0..side {
445                for b in a + 1..side {
446                    edges.push((group * 100 + a, group * 100 + b));
447                }
448            }
449        }
450        let end = side - 1;
451        edges.push((end, 100));
452        let s = Snapshot::of(&undirected(&edges));
453        let sampled = betweenness_with(&s, pivots);
454        let exact = betweenness_exact(&s);
455        assert!(!sampled.exact());
456        assert_eq!(sampled.pivots(), pivots);
457
458        let ends = [s.dense(end).expect("an end"), s.dense(100).expect("100")];
459        assert!(ends.contains(&sampled.top(1)[0].0));
460        assert!(ends.contains(&exact.top(1)[0].0));
461    }
462
463    /// And the estimate has to be near the real number, not just in the right
464    /// order. Sampling half the sources gets an individual node wrong by a
465    /// fifth of the largest score now and then, which is what an estimate is,
466    /// so what is checked is the error across the whole graph: on average a
467    /// small fraction of the largest score, and never wildly out.
468    ///
469    /// Not shrunk. How close a sample lands is a claim about the sample and the
470    /// graph together, and both bounds below are averages over the nodes, so a
471    /// graph small enough for Miri would be one where the average is over a
472    /// dozen numbers and passes or fails on which dozen.
473    #[cfg_attr(
474        miri,
475        ignore = "how close the estimate lands is the claim and an average needs the graph"
476    )]
477    #[test]
478    fn the_estimate_is_close() {
479        let mut rng = Rng::new(0xb180);
480        let nodes = 200u64;
481        let edges: Vec<(u64, u64)> = (0..nodes * 4)
482            .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
483            .collect();
484        let s = Snapshot::of(&undirected(&edges));
485        let exact = betweenness_exact(&s);
486        let sampled = betweenness_with(&s, 100);
487        let most = exact.top(1)[0].1;
488
489        let apart: Vec<f64> = (0..s.nodes())
490            .map(|node| (sampled.of(node) - exact.of(node)).abs())
491            .collect();
492        let mean = apart.iter().sum::<f64>() / f64::from(s.nodes());
493        let worst = apart.iter().copied().fold(0f64, f64::max);
494        assert!(mean < most / 20.0, "{mean} on average out of {most}");
495        assert!(worst < most / 3.0, "{worst} at worst out of {most}");
496    }
497
498    #[test]
499    fn asking_for_everybody_is_the_exact_answer() {
500        let s = Snapshot::of(&undirected(&[(1, 2), (2, 3), (3, 4)]));
501        let all = betweenness_with(&s, 99);
502        assert!(all.exact());
503        assert_eq!(all.scores(), betweenness_exact(&s).scores());
504    }
505
506    #[test]
507    fn nothing_at_all() {
508        let c = betweenness(&Snapshot::default());
509        assert!(c.scores().is_empty());
510        assert!(c.top(3).is_empty());
511        assert_eq!(c.pivots(), 0);
512    }
513
514    #[test]
515    fn a_graph_with_no_edges() {
516        let mut g = Graph::new();
517        for id in 0..4u64 {
518            g.add_node(id).expect("a node");
519        }
520        let c = betweenness(&Snapshot::of(&g));
521        assert!(c.scores().iter().all(|score| *score == 0.0));
522    }
523
524    /// Direction is the whole answer here, unlike in the community algorithms.
525    #[test]
526    fn one_way_edges_are_read_one_way() {
527        // A path that only runs one way, so 2 is in the middle of exactly one
528        // ordered pair rather than two.
529        let s = Snapshot::of(&linked(&[(1, 2), (2, 3)]));
530        let c = betweenness_exact(&s);
531        assert!((c.of(s.dense(2).expect("2")) - 1.0).abs() < 1e-9);
532    }
533
534    /// Two shortest paths through different nodes split the credit.
535    #[test]
536    fn a_tie_is_shared() {
537        // 1 reaches 4 through either 2 or 3, in two hops either way.
538        let s = Snapshot::of(&linked(&[(1, 2), (1, 3), (2, 4), (3, 4)]));
539        let c = betweenness_exact(&s);
540        assert!((c.of(s.dense(2).expect("2")) - 0.5).abs() < 1e-9);
541        assert!((c.of(s.dense(3).expect("3")) - 0.5).abs() < 1e-9);
542    }
543
544    #[test]
545    fn two_runs_agree() {
546        let mut rng = Rng::new(0xb181);
547        // Two runs agree on any graph, so a small one under Miri.
548        let (wanted, nodes) = if cfg!(miri) { (30, 12) } else { (200, 60) };
549        let edges: Vec<(u64, u64)> = (0..wanted)
550            .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
551            .collect();
552        let s = Snapshot::of(&undirected(&edges));
553        assert_eq!(
554            betweenness_with(&s, 10).scores(),
555            betweenness_with(&s, 10).scores()
556        );
557    }
558}