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}