yo_graph/algo/pagerank.rs
1//! PageRank, pulled rather than pushed.
2//!
3//! Page, Brin, Motwani and Winograd, "The PageRank Citation Ranking: Bringing
4//! Order to the Web", 1999, in the pull form the GAP benchmark suite measures.
5//!
6//! # Pull, not push
7//!
8//! The same round can be written two ways. Push walks the outgoing edges and
9//! adds each node's share into its neighbours, which writes to a random place in
10//! the score array for every edge in the graph. Pull walks the incoming edges
11//! and reads each neighbour's share, which reads from a random place and writes
12//! to one that is sequential.
13//!
14//! Both do the same arithmetic and the pull form is faster on one core for the
15//! ordinary reason: a random read can be in flight next to nine other random
16//! reads, and a random write cannot be reordered against a read of the same
17//! array. It is also the form that stays correct if somebody adds threads later,
18//! because two threads pulling into different nodes never write the same word,
19//! whereas two threads pushing into the same neighbour do.
20//!
21//! # Dangling nodes get their mass back
22//!
23//! A node with no outgoing edges has a score and nowhere to send it. The GAP
24//! implementation drops it, so the vector it computes sums to less than one and
25//! the shortfall grows with how many dead ends a graph has. That is fine for
26//! ranking, since every score is short by roughly the same factor, and it is
27//! wrong for anything that reads a score as a probability.
28//!
29//! So the dangling mass is collected each round and spread over every node,
30//! which is the random surfer restarting when they hit a page with no links, and
31//! which is what the original paper describes. The vector sums to one, and the
32//! cost is one extra pass over an array that was being read anyway.
33//!
34//! # Where the precision goes
35//!
36//! Scores are `f32` and every sum is accumulated in `f64` before it is stored.
37//! The array of shares is read once per edge from a random offset, so its size
38//! is what the round costs on a big graph, and eight bytes a node would double
39//! that traffic to buy precision that ranking cannot use. The accumulator is the
40//! part that actually needs the bits, because a node with a million incoming
41//! edges is adding a million small numbers, and that is where a naive `f32`
42//! running total loses digits.
43//!
44//! ```
45//! use yo_graph::{Graph, NO_PROPS, Snapshot, algo};
46//!
47//! let mut g = Graph::new();
48//! // Everybody points at 99, so 99 wins.
49//! for i in 0..5u64 {
50//! g.link(i, 99, 1, NO_PROPS)?;
51//! }
52//!
53//! let r = algo::pagerank(&Snapshot::of(&g));
54//! assert!(r.converged());
55//! assert_eq!(r.top(1)[0].0, Snapshot::of(&g).dense(99).unwrap());
56//! # Ok::<(), yo_common::Error>(())
57//! ```
58
59use crate::Snapshot;
60
61/// How often the random surfer follows a link rather than starting again.
62///
63/// The paper's 0.85, and everybody else's, so a score from here is comparable
64/// with a score from anywhere else. It is worth knowing that the number is also
65/// what decides how long a round takes to converge: the error falls by a factor
66/// of the damping each round, so 0.85 needs about forty rounds to reach 1e-3 and
67/// 0.99 needs about seven hundred.
68pub const DAMPING: f32 = 0.85;
69
70/// How still the vector has to be, summed over every node, to call it done.
71pub const EPSILON: f64 = 1e-6;
72
73/// How many rounds to run before giving up on converging.
74///
75/// At the default damping the vector is still long before this, so hitting the
76/// cap means either a damping close to one or a graph that is pathological, and
77/// either way the caller wants to be told rather than made to wait.
78pub const ROUNDS: u32 = 100;
79
80/// What a run worked out, and how sure it is.
81#[derive(Debug, Clone, Default)]
82pub struct Rank {
83 of: Vec<f32>,
84 rounds: u32,
85 delta: f64,
86 settled: bool,
87}
88
89impl Rank {
90 /// One node's score.
91 ///
92 /// # Panics
93 ///
94 /// If `node` is not a node of the snapshot this was computed over.
95 #[must_use]
96 pub fn of(&self, node: u32) -> f32 {
97 self.of[node as usize]
98 }
99
100 /// Every score, indexed by dense id.
101 #[must_use]
102 pub fn scores(&self) -> &[f32] {
103 &self.of
104 }
105
106 /// How many rounds it took.
107 #[must_use]
108 pub fn rounds(&self) -> u32 {
109 self.rounds
110 }
111
112 /// How much the last round moved the vector, summed over every node.
113 #[must_use]
114 pub fn delta(&self) -> f64 {
115 self.delta
116 }
117
118 /// Whether it settled rather than running out of rounds.
119 ///
120 /// Against the epsilon this run was asked for, not the default one, so a
121 /// caller who asked for something stricter is told the truth about it.
122 #[must_use]
123 pub fn converged(&self) -> bool {
124 self.settled
125 }
126
127 /// The `k` highest scoring nodes, best first.
128 ///
129 /// Ties go to the lower dense id, so two runs over the same graph list them
130 /// in the same order. Asking for more than there are gives all of them.
131 #[must_use]
132 pub fn top(&self, k: usize) -> Vec<(u32, f32)> {
133 let mut all: Vec<(u32, f32)> = self
134 .of
135 .iter()
136 .enumerate()
137 .map(|(at, score)| (at as u32, *score))
138 .collect();
139 let k = k.min(all.len());
140 if k < all.len() {
141 // Only the first k have to be in order, and a full sort of a
142 // hundred million nodes to answer top ten is most of the run.
143 all.select_nth_unstable_by(k, |a, b| better(*a, *b));
144 all.truncate(k);
145 }
146 all.sort_unstable_by(|a, b| better(*a, *b));
147 all
148 }
149}
150
151/// Higher score first, and the lower node when they are the same.
152fn better(a: (u32, f32), b: (u32, f32)) -> std::cmp::Ordering {
153 b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))
154}
155
156/// PageRank at the usual damping, to the usual precision.
157#[must_use]
158pub fn pagerank(g: &Snapshot) -> Rank {
159 pagerank_with(g, DAMPING, EPSILON, ROUNDS)
160}
161
162/// PageRank with the three numbers spelled out.
163///
164/// `epsilon` is on the sum of the per node change, not on the largest one, so it
165/// gets harder to reach as the graph gets bigger. That is deliberate: it is the
166/// L1 distance between one round and the next, which is the thing that says the
167/// distribution has stopped moving.
168#[must_use]
169pub fn pagerank_with(g: &Snapshot, damping: f32, epsilon: f64, rounds: u32) -> Rank {
170 let n = g.nodes() as usize;
171 if n == 0 {
172 // Nothing to move, so nothing left to settle.
173 return Rank {
174 settled: true,
175 ..Rank::default()
176 };
177 }
178
179 let start = 1.0 / n as f32;
180 let mut score = vec![start; n];
181 let mut share = vec![0f32; n];
182 let mut delta = f64::INFINITY;
183 let mut round = 0;
184
185 while round < rounds && delta >= epsilon {
186 // What each node hands to each of its neighbours this round, worked out
187 // once so the inner loop is a read rather than a read and a divide.
188 let mut stuck = 0f64;
189 for node in 0..n {
190 let out = g.out_degree(node as u32);
191 if out == 0 {
192 stuck += f64::from(score[node]);
193 share[node] = 0.0;
194 } else {
195 share[node] = score[node] / out as f32;
196 }
197 }
198 // Everyone gets the same floor: the surfer who jumped, plus the surfer
199 // who landed on a dead end and had to jump.
200 let base = ((1.0 - f64::from(damping)) + f64::from(damping) * stuck) / n as f64;
201
202 delta = 0.0;
203 for (node, score) in score.iter_mut().enumerate() {
204 let mut sum = 0f64;
205 for from in g.into_(node as u32) {
206 sum += f64::from(share[*from as usize]);
207 }
208 let next = base + f64::from(damping) * sum;
209 delta += (next - f64::from(*score)).abs();
210 *score = next as f32;
211 }
212 round += 1;
213 }
214
215 Rank {
216 of: score,
217 rounds: round,
218 delta,
219 settled: delta < epsilon,
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::graph::NO_PROPS;
227 use crate::{Graph, Snapshot};
228 use yo_common::Rng;
229
230 /// The same iteration written the obvious way, in double the precision, as
231 /// the thing the real one has to agree with.
232 fn reference(g: &Snapshot, rounds: u32) -> Vec<f64> {
233 let n = g.nodes() as usize;
234 let d = f64::from(DAMPING);
235 let mut score = vec![1.0 / n as f64; n];
236 for _ in 0..rounds {
237 let mut next = vec![0f64; n];
238 let mut stuck = 0f64;
239 for (node, score) in score.iter().enumerate() {
240 let out = g.out_degree(node as u32);
241 if out == 0 {
242 stuck += score;
243 } else {
244 let share = score / f64::from(out);
245 for to in g.out(node as u32) {
246 next[*to as usize] += share;
247 }
248 }
249 }
250 let base = ((1.0 - d) + d * stuck) / n as f64;
251 for got in &mut next {
252 *got = base + d * *got;
253 }
254 score = next;
255 }
256 score
257 }
258
259 fn linked(edges: &[(u64, u64)]) -> Graph {
260 let mut g = Graph::new();
261 for (from, to) in edges {
262 g.link(*from, *to, 1, NO_PROPS).expect("an edge");
263 }
264 g
265 }
266
267 #[test]
268 fn a_ring_gives_everybody_the_same_score() {
269 let edges: Vec<(u64, u64)> = (0..10u64).map(|i| (i, (i + 1) % 10)).collect();
270 let s = Snapshot::of(&linked(&edges));
271 let r = pagerank(&s);
272 assert!(r.converged(), "a ring settles");
273 for node in 0..s.nodes() {
274 assert!((r.of(node) - 0.1).abs() < 1e-5, "{}", r.of(node));
275 }
276 }
277
278 #[test]
279 fn the_node_everybody_points_at_wins() {
280 let edges: Vec<(u64, u64)> = (0..20u64).map(|i| (i, 100)).collect();
281 let s = Snapshot::of(&linked(&edges));
282 let r = pagerank(&s);
283 let hub = s.dense(100).expect("the hub");
284 let top = r.top(3);
285 assert_eq!(top[0].0, hub);
286 // Everybody else is a leaf with nothing pointing at them, so they are
287 // all on the floor and the hub is far above it.
288 assert!(top[0].1 > 10.0 * top[1].1, "{top:?}");
289 }
290
291 #[test]
292 fn the_scores_add_up_to_one() {
293 // A graph with dead ends in it, which is the case where dropping the
294 // dangling mass would show.
295 let s = Snapshot::of(&linked(&[(0, 1), (1, 2), (3, 1), (4, 5)]));
296 let r = pagerank(&s);
297 let total: f64 = r.scores().iter().map(|s| f64::from(*s)).sum();
298 assert!((total - 1.0).abs() < 1e-4, "{total}");
299 }
300
301 #[test]
302 fn no_damping_is_the_uniform_vector() {
303 let s = Snapshot::of(&linked(&[(0, 1), (1, 2), (2, 0), (3, 0)]));
304 let r = pagerank_with(&s, 0.0, EPSILON, ROUNDS);
305 for node in 0..s.nodes() {
306 assert!((r.of(node) - 0.25).abs() < 1e-6, "{}", r.of(node));
307 }
308 }
309
310 #[test]
311 fn an_empty_graph_has_no_scores() {
312 let r = pagerank(&Snapshot::default());
313 assert!(r.scores().is_empty());
314 assert_eq!(r.rounds(), 0);
315 assert!(r.top(5).is_empty());
316 }
317
318 #[test]
319 fn one_node_holds_everything() {
320 let mut g = Graph::new();
321 g.add_node(7).expect("a node");
322 let r = pagerank(&Snapshot::of(&g));
323 assert!((r.of(0) - 1.0).abs() < 1e-6, "{}", r.of(0));
324 }
325
326 #[test]
327 fn a_self_loop_keeps_what_it_is_given() {
328 let s = Snapshot::of(&linked(&[(0, 0), (1, 0), (2, 0)]));
329 let r = pagerank(&s);
330 let sink = s.dense(0).expect("the sink");
331 assert!(r.of(sink) > 0.7, "{}", r.of(sink));
332 }
333
334 #[test]
335 fn two_runs_agree_to_the_bit() {
336 let mut rng = Rng::new(0x51ee);
337 // Two runs agree on any graph, so a small one under Miri.
338 let (wanted, nodes) = if cfg!(miri) { (60, 30) } else { (2000, 300) };
339 let edges: Vec<(u64, u64)> = (0..wanted)
340 .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
341 .collect();
342 let s = Snapshot::of(&linked(&edges));
343 assert_eq!(pagerank(&s).scores(), pagerank(&s).scores());
344 }
345
346 #[test]
347 fn it_says_when_it_ran_out_of_rounds() {
348 let s = Snapshot::of(&linked(&[(0, 1), (1, 2), (2, 0)]));
349 let r = pagerank_with(&s, DAMPING, 1e-30, 5);
350 assert_eq!(r.rounds(), 5);
351 assert!(!r.converged());
352 assert!(r.delta() > 0.0);
353 }
354
355 /// Against the obvious implementation, over graphs nobody chose.
356 #[test]
357 fn it_agrees_with_the_slow_one() {
358 let mut rng = Rng::new(0xbead);
359 // Fewer and smaller cases under Miri. Three edges a node is kept,
360 // because the thing that separates the two is what they do with a node
361 // that points nowhere and how the rank of one is spread, and that is
362 // the degree rather than the size.
363 let (cases, spread) = if cfg!(miri) { (3, 8) } else { (40, 60) };
364 for case in 0..cases {
365 let nodes = 2 + rng.next_u64() % spread;
366 let edges: Vec<(u64, u64)> = (0..nodes * 3)
367 .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
368 .collect();
369 let s = Snapshot::of(&linked(&edges));
370 let mine = pagerank(&s);
371 let theirs = reference(&s, mine.rounds());
372 for node in 0..s.nodes() {
373 let (a, b) = (f64::from(mine.of(node)), theirs[node as usize]);
374 assert!((a - b).abs() < 1e-5, "case {case} node {node}: {a} {b}");
375 }
376 }
377 }
378}