Skip to main content

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        let edges: Vec<(u64, u64)> = (0..2000)
338            .map(|_| (rng.next_u64() % 300, rng.next_u64() % 300))
339            .collect();
340        let s = Snapshot::of(&linked(&edges));
341        assert_eq!(pagerank(&s).scores(), pagerank(&s).scores());
342    }
343
344    #[test]
345    fn it_says_when_it_ran_out_of_rounds() {
346        let s = Snapshot::of(&linked(&[(0, 1), (1, 2), (2, 0)]));
347        let r = pagerank_with(&s, DAMPING, 1e-30, 5);
348        assert_eq!(r.rounds(), 5);
349        assert!(!r.converged());
350        assert!(r.delta() > 0.0);
351    }
352
353    /// Against the obvious implementation, over graphs nobody chose.
354    #[test]
355    fn it_agrees_with_the_slow_one() {
356        let mut rng = Rng::new(0xbead);
357        for case in 0..40 {
358            let nodes = 2 + rng.next_u64() % 60;
359            let edges: Vec<(u64, u64)> = (0..nodes * 3)
360                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
361                .collect();
362            let s = Snapshot::of(&linked(&edges));
363            let mine = pagerank(&s);
364            let theirs = reference(&s, mine.rounds());
365            for node in 0..s.nodes() {
366                let (a, b) = (f64::from(mine.of(node)), theirs[node as usize]);
367                assert!((a - b).abs() < 1e-5, "case {case} node {node}: {a} {b}");
368            }
369        }
370    }
371}