Skip to main content

rust_igraph/algorithms/properties/
personalized_pagerank.rs

1//! Personalized `PageRank` (ALGO-PR-055).
2//!
3//! Counterpart of `igraph_personalized_pagerank()` from
4//! `references/igraph/src/centrality/pagerank.c`.
5//!
6//! Unlike standard `PageRank` which teleports uniformly to 1/N,
7//! personalized `PageRank` uses a custom reset distribution vector.
8//! This enables topic-sensitive ranking, local community detection,
9//! and link prediction.
10
11use crate::core::{Graph, IgraphError, IgraphResult};
12
13const DEFAULT_DAMPING: f64 = 0.85;
14const DEFAULT_EPS: f64 = 1e-10;
15const DEFAULT_MAX_ITER: usize = 1000;
16
17/// Personalized `PageRank` scores via power iteration.
18///
19/// `reset`: the personalization vector. Must have length `vcount()`
20/// and contain non-negative values that sum to a positive number
21/// (they are internally normalized to sum to 1). Vertices with
22/// higher reset weight attract more rank during teleportation.
23///
24/// `damping`: the damping factor (probability of following edges
25/// vs. teleporting). Must be in (0, 1). Use `0.85` for standard
26/// behavior.
27///
28/// Returns a `Vec<f64>` summing approximately to 1.
29///
30/// Counterpart of
31/// `igraph_personalized_pagerank(_, IGRAPH_PAGERANK_ALGO_POWER, _,
32///  _, vss_all(), directed, damping, reset, weights=NULL, _)`
33///
34/// # Errors
35///
36/// - `InvalidArgument` if `reset` length does not match `vcount()`.
37/// - `InvalidArgument` if `reset` contains negative values.
38/// - `InvalidArgument` if `reset` sums to zero.
39/// - `InvalidArgument` if `damping` is not in (0, 1).
40///
41/// # Examples
42///
43/// ```
44/// use rust_igraph::{Graph, personalized_pagerank};
45///
46/// // 4-cycle: bias teleportation toward vertex 1.
47/// let mut g = Graph::with_vertices(4);
48/// g.add_edge(0, 1).unwrap();
49/// g.add_edge(1, 2).unwrap();
50/// g.add_edge(2, 3).unwrap();
51/// g.add_edge(3, 0).unwrap();
52/// // Reset only on vertex 1 — it gets the highest PR.
53/// let reset = vec![0.0, 1.0, 0.0, 0.0];
54/// let pr = personalized_pagerank(&g, &reset, 0.85).unwrap();
55/// assert!(pr[1] > pr[0]);
56/// assert!(pr[1] > pr[2]);
57/// assert!(pr[1] > pr[3]);
58///
59/// // Uniform reset: all vertices equal on a symmetric graph.
60/// let uniform = vec![0.25, 0.25, 0.25, 0.25];
61/// let pr_uniform = personalized_pagerank(&g, &uniform, 0.85).unwrap();
62/// let sum: f64 = pr_uniform.iter().sum();
63/// assert!((sum - 1.0).abs() < 1e-9);
64/// ```
65pub fn personalized_pagerank(graph: &Graph, reset: &[f64], damping: f64) -> IgraphResult<Vec<f64>> {
66    let n = graph.vcount();
67    let n_us = n as usize;
68
69    if n == 0 {
70        if reset.is_empty() {
71            return Ok(Vec::new());
72        }
73        return Err(IgraphError::InvalidArgument(
74            "personalized_pagerank: reset vector non-empty but graph has no vertices".into(),
75        ));
76    }
77
78    if reset.len() != n_us {
79        return Err(IgraphError::InvalidArgument(format!(
80            "personalized_pagerank: reset length ({}) does not match vcount ({n})",
81            reset.len()
82        )));
83    }
84
85    if damping <= 0.0 || damping >= 1.0 {
86        return Err(IgraphError::InvalidArgument(format!(
87            "personalized_pagerank: damping ({damping}) must be in (0, 1)"
88        )));
89    }
90
91    let reset_sum: f64 = reset.iter().sum();
92    if reset_sum <= 0.0 {
93        return Err(IgraphError::InvalidArgument(
94            "personalized_pagerank: reset vector must sum to a positive value".into(),
95        ));
96    }
97    for (i, &val) in reset.iter().enumerate() {
98        if val < 0.0 {
99            return Err(IgraphError::InvalidArgument(format!(
100                "personalized_pagerank: negative reset value ({val}) at index {i}"
101            )));
102        }
103    }
104
105    if n == 1 {
106        return Ok(vec![1.0]);
107    }
108
109    // Normalize the reset vector.
110    let inv_sum = 1.0 / reset_sum;
111    let norm_reset: Vec<f64> = reset.iter().map(|&v| v * inv_sum).collect();
112
113    let directed = graph.is_directed();
114
115    // Out-degree per vertex.
116    let mut out_deg = vec![0u64; n_us];
117    for v in 0..n {
118        let nbrs = graph.neighbors(v)?;
119        out_deg[v as usize] = nbrs.len() as u64;
120    }
121
122    // Build in-adjacency lists.
123    let m =
124        u32::try_from(graph.ecount()).map_err(|_| IgraphError::Internal("ecount overflows u32"))?;
125    let mut in_adj: Vec<Vec<u32>> = vec![Vec::new(); n_us];
126
127    if directed {
128        for e in 0..m {
129            let (u, v) = graph.edge(e)?;
130            in_adj[v as usize].push(u);
131        }
132    } else {
133        for e in 0..m {
134            let (u, v) = graph.edge(e)?;
135            if u == v {
136                in_adj[v as usize].push(u);
137                in_adj[v as usize].push(u);
138            } else {
139                in_adj[u as usize].push(v);
140                in_adj[v as usize].push(u);
141            }
142        }
143    }
144
145    // Initial distribution from the reset vector.
146    let mut pr = norm_reset.clone();
147    let mut pr_new = vec![0.0_f64; n_us];
148
149    for _ in 0..DEFAULT_MAX_ITER {
150        // Dangling vertex rank sum.
151        let mut dangling_sum: f64 = 0.0;
152        for v in 0..n_us {
153            if out_deg[v] == 0 {
154                dangling_sum += pr[v];
155            }
156        }
157
158        for v in 0..n_us {
159            let mut incoming: f64 = 0.0;
160            for &u in &in_adj[v] {
161                #[allow(clippy::cast_precision_loss)]
162                let denom = out_deg[u as usize] as f64;
163                if denom > 0.0 {
164                    incoming += pr[u as usize] / denom;
165                }
166            }
167            // Personalized teleport: use norm_reset[v] instead of 1/N.
168            // Dangling nodes also distribute according to norm_reset.
169            pr_new[v] = (1.0 - damping) * norm_reset[v]
170                + damping * (incoming + dangling_sum * norm_reset[v]);
171        }
172
173        // Convergence check: L1 norm.
174        let mut diff: f64 = 0.0;
175        for v in 0..n_us {
176            diff += (pr_new[v] - pr[v]).abs();
177        }
178        std::mem::swap(&mut pr, &mut pr_new);
179        if diff < DEFAULT_EPS {
180            break;
181        }
182    }
183
184    Ok(pr)
185}
186
187/// Personalized `PageRank` with default damping factor (0.85).
188///
189/// Convenience wrapper around [`personalized_pagerank`] with
190/// `damping = 0.85`.
191///
192/// # Examples
193///
194/// ```
195/// use rust_igraph::{Graph, personalized_pagerank_default};
196///
197/// let mut g = Graph::with_vertices(3);
198/// g.add_edge(0, 1).unwrap();
199/// g.add_edge(1, 2).unwrap();
200/// g.add_edge(2, 0).unwrap();
201/// let reset = vec![1.0, 0.0, 0.0]; // bias toward vertex 0
202/// let pr = personalized_pagerank_default(&g, &reset).unwrap();
203/// assert!(pr[0] > pr[1]);
204/// assert!(pr[0] > pr[2]);
205/// ```
206pub fn personalized_pagerank_default(graph: &Graph, reset: &[f64]) -> IgraphResult<Vec<f64>> {
207    personalized_pagerank(graph, reset, DEFAULT_DAMPING)
208}
209
210/// Personalized `PageRank` with a vertex-set reset distribution.
211///
212/// Convenience wrapper around [`personalized_pagerank`] that constructs
213/// a uniform reset vector over the specified `reset_vids`. Teleportation
214/// probability is distributed equally among the given vertices.
215///
216/// Counterpart of `igraph_personalized_pagerank_vs()`.
217///
218/// # Errors
219///
220/// - `InvalidArgument` if `reset_vids` is empty.
221/// - `InvalidArgument` if any vertex in `reset_vids` is out of range.
222/// - `InvalidArgument` if `damping` is not in (0, 1).
223///
224/// # Examples
225///
226/// ```
227/// use rust_igraph::{Graph, personalized_pagerank_vs};
228///
229/// let mut g = Graph::with_vertices(4);
230/// g.add_edge(0, 1).unwrap();
231/// g.add_edge(1, 2).unwrap();
232/// g.add_edge(2, 3).unwrap();
233/// g.add_edge(3, 0).unwrap();
234/// // Teleport only to vertices 0 and 1
235/// let pr = personalized_pagerank_vs(&g, &[0, 1], 0.85).unwrap();
236/// let sum: f64 = pr.iter().sum();
237/// assert!((sum - 1.0).abs() < 1e-9);
238/// // Vertices 0 and 1 should get more rank than 2 and 3
239/// assert!(pr[0] + pr[1] > pr[2] + pr[3]);
240/// ```
241pub fn personalized_pagerank_vs(
242    graph: &Graph,
243    reset_vids: &[u32],
244    damping: f64,
245) -> IgraphResult<Vec<f64>> {
246    let n = graph.vcount();
247    let n_us = n as usize;
248
249    if reset_vids.is_empty() {
250        return Err(IgraphError::InvalidArgument(
251            "reset_vids must not be empty".into(),
252        ));
253    }
254
255    let mut reset = vec![0.0_f64; n_us];
256    #[allow(clippy::cast_precision_loss)]
257    let weight = 1.0 / reset_vids.len() as f64;
258    for &v in reset_vids {
259        if v >= n {
260            return Err(IgraphError::InvalidArgument(format!(
261                "reset vertex {v} out of range (vcount = {n})"
262            )));
263        }
264        reset[v as usize] += weight;
265    }
266
267    personalized_pagerank(graph, &reset, damping)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn close(actual: &[f64], expected: &[f64], tol: f64) {
275        assert_eq!(actual.len(), expected.len(), "length mismatch");
276        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
277            assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
278        }
279    }
280
281    #[test]
282    fn empty_graph() {
283        let g = Graph::with_vertices(0);
284        let pr = personalized_pagerank(&g, &[], 0.85).unwrap();
285        assert!(pr.is_empty());
286    }
287
288    #[test]
289    fn singleton() {
290        let g = Graph::with_vertices(1);
291        let pr = personalized_pagerank(&g, &[1.0], 0.85).unwrap();
292        assert_eq!(pr, vec![1.0]);
293    }
294
295    #[test]
296    fn uniform_reset_matches_standard_pagerank() {
297        let mut g = Graph::with_vertices(4);
298        g.add_edge(0, 1).unwrap();
299        g.add_edge(0, 2).unwrap();
300        g.add_edge(1, 2).unwrap();
301        g.add_edge(2, 3).unwrap();
302
303        let standard = crate::pagerank(&g).unwrap();
304        let uniform = vec![0.25; 4];
305        let personalized = personalized_pagerank(&g, &uniform, 0.85).unwrap();
306        close(&personalized, &standard, 1e-9);
307    }
308
309    #[test]
310    fn biased_reset_changes_ranking() {
311        // Star: center 0 connected to leaves 1, 2, 3.
312        let mut g = Graph::with_vertices(4);
313        g.add_edge(0, 1).unwrap();
314        g.add_edge(0, 2).unwrap();
315        g.add_edge(0, 3).unwrap();
316
317        // Uniform reset: center has highest PR (absorbs from all leaves).
318        let uniform = vec![0.25; 4];
319        let pr_u = personalized_pagerank(&g, &uniform, 0.85).unwrap();
320        assert!(pr_u[0] > pr_u[1]);
321
322        // Reset only on leaf 1: leaf 1 gets more teleport, but center
323        // still collects from all leaves. Leaf 1 should still be above
324        // leaves 2 and 3 (which get zero teleport).
325        let biased = vec![0.0, 1.0, 0.0, 0.0];
326        let pr_b = personalized_pagerank(&g, &biased, 0.85).unwrap();
327        assert!(pr_b[1] > pr_b[2], "leaf 1 > leaf 2");
328        assert!(pr_b[1] > pr_b[3], "leaf 1 > leaf 3");
329        // Center still dominates in star topology.
330        assert!(pr_b[0] > pr_b[1], "center > biased leaf in star");
331    }
332
333    #[test]
334    fn sums_to_one() {
335        let mut g = Graph::with_vertices(5);
336        g.add_edge(0, 1).unwrap();
337        g.add_edge(1, 2).unwrap();
338        g.add_edge(2, 3).unwrap();
339        g.add_edge(3, 4).unwrap();
340        g.add_edge(4, 0).unwrap();
341
342        let reset = vec![0.5, 0.2, 0.1, 0.1, 0.1];
343        let pr = personalized_pagerank(&g, &reset, 0.85).unwrap();
344        let total: f64 = pr.iter().sum();
345        assert!((total - 1.0).abs() < 1e-9, "sum={total}");
346    }
347
348    #[test]
349    fn directed_biased() {
350        // 0→1→2, reset on 0.
351        let mut g = Graph::new(3, true).unwrap();
352        g.add_edge(0, 1).unwrap();
353        g.add_edge(1, 2).unwrap();
354
355        let reset = vec![1.0, 0.0, 0.0];
356        let pr = personalized_pagerank(&g, &reset, 0.85).unwrap();
357        // Flow: 0 sends to 1, 1 sends to 2. 2 is dangling → distributes back to 0.
358        // 0 also gets all teleport. So pr[0] > pr[1] > pr[2] is NOT necessarily true.
359        // Actually: 2 is dangling, distributes reset-weighted (all to 0).
360        // Teleport goes to 0 only.
361        // So 0 gets lots of rank; 1 gets from 0; 2 gets from 1.
362        // pr[0] > pr[1] > pr[2].
363        assert!(pr[0] > pr[1], "pr[0]={} > pr[1]={}", pr[0], pr[1]);
364        assert!(pr[1] > pr[2], "pr[1]={} > pr[2]={}", pr[1], pr[2]);
365        let total: f64 = pr.iter().sum();
366        assert!((total - 1.0).abs() < 1e-9);
367    }
368
369    #[test]
370    fn dangling_vertices_redistribute_to_reset() {
371        // 0→1, both vertices. Vertex 1 is dangling.
372        // Reset = [0.3, 0.7]. Dangling redistribution goes to reset weights.
373        let mut g = Graph::new(2, true).unwrap();
374        g.add_edge(0, 1).unwrap();
375        let reset = vec![0.3, 0.7];
376        let pr = personalized_pagerank(&g, &reset, 0.85).unwrap();
377        let total: f64 = pr.iter().sum();
378        assert!((total - 1.0).abs() < 1e-9);
379    }
380
381    #[test]
382    fn different_damping_factors() {
383        let mut g = Graph::with_vertices(3);
384        g.add_edge(0, 1).unwrap();
385        g.add_edge(1, 2).unwrap();
386        g.add_edge(2, 0).unwrap();
387        let reset = vec![1.0, 0.0, 0.0];
388
389        // High damping → more link-following → more uniform.
390        let pr_high = personalized_pagerank(&g, &reset, 0.99).unwrap();
391        // Low damping → more teleporting → more biased toward reset.
392        let pr_low = personalized_pagerank(&g, &reset, 0.5).unwrap();
393        // With low damping, vertex 0 should have more weight.
394        assert!(pr_low[0] > pr_high[0]);
395    }
396
397    #[test]
398    fn error_on_wrong_reset_length() {
399        let g = Graph::with_vertices(3);
400        assert!(personalized_pagerank(&g, &[1.0, 1.0], 0.85).is_err());
401    }
402
403    #[test]
404    fn error_on_negative_reset() {
405        let g = Graph::with_vertices(3);
406        assert!(personalized_pagerank(&g, &[1.0, -0.5, 0.5], 0.85).is_err());
407    }
408
409    #[test]
410    fn error_on_zero_sum_reset() {
411        let g = Graph::with_vertices(3);
412        assert!(personalized_pagerank(&g, &[0.0, 0.0, 0.0], 0.85).is_err());
413    }
414
415    #[test]
416    fn error_on_invalid_damping() {
417        let g = Graph::with_vertices(2);
418        assert!(personalized_pagerank(&g, &[0.5, 0.5], 0.0).is_err());
419        assert!(personalized_pagerank(&g, &[0.5, 0.5], 1.0).is_err());
420        assert!(personalized_pagerank(&g, &[0.5, 0.5], -0.1).is_err());
421        assert!(personalized_pagerank(&g, &[0.5, 0.5], 1.5).is_err());
422    }
423
424    #[test]
425    fn unnormalized_reset_works() {
426        let mut g = Graph::with_vertices(3);
427        g.add_edge(0, 1).unwrap();
428        g.add_edge(1, 2).unwrap();
429        g.add_edge(2, 0).unwrap();
430        // Reset = [10, 0, 0] should give same result as [1, 0, 0].
431        let pr1 = personalized_pagerank(&g, &[10.0, 0.0, 0.0], 0.85).unwrap();
432        let pr2 = personalized_pagerank(&g, &[1.0, 0.0, 0.0], 0.85).unwrap();
433        close(&pr1, &pr2, 1e-9);
434    }
435
436    #[test]
437    fn default_wrapper() {
438        let mut g = Graph::with_vertices(3);
439        g.add_edge(0, 1).unwrap();
440        g.add_edge(1, 2).unwrap();
441        g.add_edge(2, 0).unwrap();
442        let reset = vec![1.0, 0.0, 0.0];
443        let pr1 = personalized_pagerank(&g, &reset, 0.85).unwrap();
444        let pr2 = personalized_pagerank_default(&g, &reset).unwrap();
445        close(&pr1, &pr2, 1e-15);
446    }
447
448    #[test]
449    fn isolated_vertices_with_biased_reset() {
450        // 4 isolated vertices, reset only on vertex 2.
451        // All dangling → all rank flows through reset.
452        let g = Graph::with_vertices(4);
453        let reset = vec![0.0, 0.0, 1.0, 0.0];
454        let pr = personalized_pagerank(&g, &reset, 0.85).unwrap();
455        // All rank ends up on vertex 2.
456        assert!((pr[2] - 1.0).abs() < 1e-9);
457        assert!(pr[0].abs() < 1e-9);
458        assert!(pr[1].abs() < 1e-9);
459        assert!(pr[3].abs() < 1e-9);
460    }
461
462    #[test]
463    fn oracle_5_cycle_biased() {
464        // Verified against python-igraph personalized_pagerank.
465        let mut g = Graph::with_vertices(5);
466        g.add_edge(0, 1).unwrap();
467        g.add_edge(1, 2).unwrap();
468        g.add_edge(2, 3).unwrap();
469        g.add_edge(3, 4).unwrap();
470        g.add_edge(4, 0).unwrap();
471        let reset = vec![0.5, 0.2, 0.1, 0.1, 0.1];
472        let pr = personalized_pagerank(&g, &reset, 0.85).unwrap();
473        let expected = [
474            0.246_408_839_8,
475            0.210_246_107_5,
476            0.177_699_648_4,
477            0.172_576_594_7,
478            0.193_068_809_6,
479        ];
480        close(&pr, &expected, 1e-6);
481    }
482
483    #[test]
484    fn vs_matches_manual_reset() {
485        let mut g = Graph::with_vertices(4);
486        g.add_edge(0, 1).unwrap();
487        g.add_edge(1, 2).unwrap();
488        g.add_edge(2, 3).unwrap();
489        g.add_edge(3, 0).unwrap();
490
491        let pr_vs = personalized_pagerank_vs(&g, &[0, 1], 0.85).unwrap();
492        let reset = vec![0.5, 0.5, 0.0, 0.0];
493        let pr_manual = personalized_pagerank(&g, &reset, 0.85).unwrap();
494        close(&pr_vs, &pr_manual, 1e-12);
495    }
496
497    #[test]
498    fn vs_single_vertex() {
499        let mut g = Graph::with_vertices(3);
500        g.add_edge(0, 1).unwrap();
501        g.add_edge(1, 2).unwrap();
502        g.add_edge(2, 0).unwrap();
503
504        let pr = personalized_pagerank_vs(&g, &[2], 0.85).unwrap();
505        let sum: f64 = pr.iter().sum();
506        assert!((sum - 1.0).abs() < 1e-9);
507        assert!(pr[2] > pr[0]);
508        assert!(pr[2] > pr[1]);
509    }
510
511    #[test]
512    fn vs_empty_errors() {
513        let g = Graph::with_vertices(3);
514        assert!(personalized_pagerank_vs(&g, &[], 0.85).is_err());
515    }
516
517    #[test]
518    fn vs_out_of_range_errors() {
519        let g = Graph::with_vertices(3);
520        assert!(personalized_pagerank_vs(&g, &[5], 0.85).is_err());
521    }
522}