Skip to main content

bixverse_methods/graph/
page_rank.rs

1//! Implementations of the PageRank algorithm for rapid identification of
2//! influential nodes in the network
3
4use rayon::prelude::*;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::graph::graph_structures::edges_to_csr;
8use crate::prelude::*;
9
10/////////////
11// Helpers //
12/////////////
13
14/// Default convergence threshold on the L1 change between iterations.
15const DEFAULT_TOLERANCE: f64 = 1e-12;
16
17/// Structure for Page Rank Memory
18///
19/// Allows for faster, better usage of memory
20///
21/// ### Fields
22///
23/// * `ranks` - The old ranks
24/// * `new_ranks` - The new ranks
25#[derive(Debug)]
26pub struct PageRankWorkingMemory<T> {
27    ranks: Vec<T>,
28    new_ranks: Vec<T>,
29}
30
31impl<T> PageRankWorkingMemory<T>
32where
33    T: BixverseFloat,
34{
35    /// Initialise the structure
36    ///
37    /// ### Returns
38    ///
39    /// Initialised `PageRankWorkingMemory` structure
40    pub fn new() -> Self {
41        Self {
42            ranks: Vec::new(),
43            new_ranks: Vec::new(),
44        }
45    }
46
47    /// Ensure that the capacity is correct to avoid panics
48    ///
49    /// ### Params
50    ///
51    /// * `node_count` - The new node count.
52    fn ensure_capacity(&mut self, node_count: usize) {
53        if self.ranks.len() < node_count {
54            self.ranks.resize(node_count, T::zero());
55            self.new_ranks.resize(node_count, T::zero());
56        }
57    }
58}
59
60impl<T> Default for PageRankWorkingMemory<T>
61where
62    T: BixverseFloat,
63{
64    fn default() -> Self {
65        Self {
66            ranks: Vec::new(),
67            new_ranks: Vec::new(),
68        }
69    }
70}
71
72/// Precomputed graph structure for efficient PageRank computation
73#[derive(Clone)]
74pub struct PageRankGraph<T> {
75    /// The total number of nodes in the graph
76    node_count: usize,
77    /// Flattened incoming edges: `[node0_in_edges..., node1_in_edges..., ...]`
78    in_edges_flat: Vec<usize>,
79    /// Edge weight per entry of `in_edges_flat`
80    in_edge_weights_flat: Vec<T>,
81    /// Offsets into `in_edges_flat` for each node, length `node_count + 1`
82    in_edges_offsets: Vec<usize>,
83    /// Inverse of the summed out-weight per node, zero for dangling nodes
84    inv_out_weight: Vec<T>,
85}
86
87impl<T> PageRankGraph<T>
88where
89    T: BixverseFloat + std::iter::Sum,
90{
91    /// Generate the structure from 0-based edge indices.
92    ///
93    /// ### Params
94    ///
95    /// * `node_count` - Number of nodes
96    /// * `from` - 0-based source node per edge
97    /// * `to` - 0-based target node per edge
98    /// * `weights` - Optional weight per edge. Defaults to 1.
99    /// * `undirected` - Add every edge in both directions
100    ///
101    /// ### Returns
102    ///
103    /// Initialised `PageRankGraph` structure
104    pub fn from_indices(
105        node_count: usize,
106        from: &[usize],
107        to: &[usize],
108        weights: Option<&[T]>,
109        undirected: bool,
110    ) -> Self {
111        assert_same_len!(from, to);
112
113        if let Some(weights) = weights {
114            assert_same_len!(from, weights);
115        }
116
117        let mut out_edges: Vec<Vec<(usize, T)>> = vec![Vec::new(); node_count];
118        let mut in_edges: Vec<Vec<(usize, T)>> = vec![Vec::new(); node_count];
119
120        for (i, (&from_idx, &to_idx)) in from.iter().zip(to.iter()).enumerate() {
121            let weight = weights.map(|w| w[i]).unwrap_or(T::one());
122
123            out_edges[from_idx].push((to_idx, weight));
124            in_edges[to_idx].push((from_idx, weight));
125
126            if undirected {
127                out_edges[to_idx].push((from_idx, weight));
128                in_edges[from_idx].push((to_idx, weight));
129            }
130        }
131
132        // Flatten in_edges for better cache locality
133        let mut in_edges_flat = Vec::new();
134        let mut in_edge_weights_flat = Vec::new();
135        let mut in_edges_offsets = Vec::with_capacity(node_count + 1);
136        in_edges_offsets.push(0);
137
138        for node_in_edges in &in_edges {
139            for &(node_idx, weight) in node_in_edges {
140                in_edges_flat.push(node_idx);
141                in_edge_weights_flat.push(weight);
142            }
143            in_edges_offsets.push(in_edges_flat.len());
144        }
145
146        let inv_out_weight = inv_out_weights(&out_edges);
147
148        Self {
149            node_count,
150            in_edges_flat,
151            in_edge_weights_flat,
152            in_edges_offsets,
153            inv_out_weight,
154        }
155    }
156
157    /// Generate the structure directly from node names and edge lists
158    ///
159    /// ### Params
160    ///
161    /// * `nodes` - Slice of the node names
162    /// * `from` - Slice of the names of the from nodes
163    /// * `to` - Slice of the names of the to nodes
164    /// * `weights` - Optional weight per edge. Defaults to 1.
165    /// * `undirected` - Whether to create bidirectional edges
166    ///
167    /// ### Returns
168    ///
169    /// Initialised `PageRankGraph` structure
170    ///
171    /// ### Panics
172    ///
173    /// If an edge names a node that is not in `nodes`.
174    pub fn from_strings(
175        nodes: &[String],
176        from: &[String],
177        to: &[String],
178        weights: Option<&[T]>,
179        undirected: bool,
180    ) -> Self {
181        let name_to_idx: FxHashMap<&String, usize> = nodes
182            .iter()
183            .enumerate()
184            .map(|(idx, name)| (name, idx))
185            .collect();
186        let lookup = |name: &String| {
187            *name_to_idx
188                .get(name)
189                .unwrap_or_else(|| panic!("Node '{name}' not found in nodes list"))
190        };
191        let from_idx: Vec<usize> = from.iter().map(lookup).collect();
192        let to_idx: Vec<usize> = to.iter().map(lookup).collect();
193
194        Self::from_indices(nodes.len(), &from_idx, &to_idx, weights, undirected)
195    }
196
197    /// Get incoming edges for a node
198    ///
199    /// Inline function to hopefully optimise further the compilation of the
200    /// program
201    ///
202    /// ### Params
203    ///
204    /// * `node` - Get the in_edges for a given node index.
205    ///
206    /// ### Return
207    ///
208    /// Returns a slice of in_edges
209    #[inline]
210    fn in_edges(&self, node: usize) -> (&[usize], &[T]) {
211        let start = self.in_edges_offsets[node];
212        let end = self.in_edges_offsets[node + 1];
213        (
214            &self.in_edges_flat[start..end],
215            &self.in_edge_weights_flat[start..end],
216        )
217    }
218}
219
220/// Inverse summed out-weight per node.
221///
222/// Nodes whose out-weight sums to zero (no out-edges, or only zero-weight
223/// ones) get zero, so their mass leaks. The callers renormalise at the end,
224/// which for personalised PageRank is the same as teleporting the dangling
225/// mass back to the personalisation vector: the solution is only rescaled.
226///
227/// ### Params
228///
229/// * `out_edges` - Out-edges per node as `(target, weight)`
230///
231/// ### Returns
232///
233/// The inverse out-weight per node.
234fn inv_out_weights<T>(out_edges: &[Vec<(usize, T)>]) -> Vec<T>
235where
236    T: BixverseFloat + std::iter::Sum,
237{
238    out_edges
239        .iter()
240        .map(|edges| {
241            let total: T = edges.iter().map(|(_, weight)| *weight).sum();
242            if total > T::zero() {
243                T::one() / total
244            } else {
245                T::zero()
246            }
247        })
248        .collect()
249}
250
251//////////////
252// PageRank //
253//////////////
254
255/// Parallel personalised PageRank algorithm for a single run.
256///
257/// Allocates its own working memory. For many runs on one graph, call
258/// [personalised_page_rank_optimised] with thread-local working memory instead.
259///
260/// ### Params
261///
262/// * `graph` - The `PageRankGraph` on which to run the personalised page-rank.
263/// * `damping_factor` - The dampening factor parameter, i.e., the probability
264///   of continuing the walk.
265/// * `personalisation_vector` - The vector of probabilities for the reset,
266///   making this the personalised page rank.
267/// * `nb_iter` - Maximum number of iterations for the personalised page rank.
268/// * `tol` - Optional convergence threshold on the L1 change between
269///   iterations. Defaults to `1e-12`.
270///
271/// ### Returns
272///
273/// The (normalised) personalised page rank scores.
274pub fn personalised_page_rank<T>(
275    graph: &PageRankGraph<T>,
276    damping_factor: T,
277    personalisation_vector: &[T],
278    nb_iter: usize,
279    tol: Option<T>,
280) -> Vec<T>
281where
282    T: BixverseFloat + std::iter::Sum,
283{
284    if graph.node_count == 0 {
285        return vec![];
286    }
287
288    assert!(
289        T::zero() <= damping_factor && damping_factor <= T::one(),
290        "Damping factor should be between 0 and 1."
291    );
292    assert_eq!(
293        personalisation_vector.len(),
294        graph.node_count,
295        "Personalisation vector length must match node count."
296    );
297
298    let tolerance = tol.unwrap_or(T::from_f64(DEFAULT_TOLERANCE).unwrap());
299
300    personalised_page_rank_optimised(
301        graph,
302        damping_factor,
303        personalisation_vector,
304        nb_iter,
305        tolerance,
306        &mut PageRankWorkingMemory::new(),
307    )
308}
309
310/// Optimised PageRank with pre-allocated working memory
311///
312/// This is a highly optimised version of the personalised page rank to be used
313/// for rapid permutations.
314///
315/// ### Params
316///
317/// * `graph` - The `PageRankGraph` structure with pre-computed values for
318///   fast calculations
319/// * `damping_factor` - The dampening factor parameter, i.e., the probability
320///   of resetting.
321/// * `personalization_vector` - The vector of probabilities for the reset,
322///   making this the personalised page rank.
323/// * `nb_iter` - Maximum number of iterations for the personalised page rank.
324/// * `tolerance` - Convergence threshold on the L1 change between iterations.
325/// * `working_memory` - The `PageRankWorkingMemory` structure to store the old
326///   and new ranks
327///
328/// ### Returns
329///
330/// The (normalised) personalised page rank scores.
331pub fn personalised_page_rank_optimised<T>(
332    graph: &PageRankGraph<T>,
333    damping_factor: T,
334    personalisation_vector: &[T],
335    nb_iter: usize,
336    tolerance: T,
337    working_memory: &mut PageRankWorkingMemory<T>,
338) -> Vec<T>
339where
340    T: BixverseFloat + std::iter::Sum,
341{
342    let node_count = graph.node_count;
343
344    // reuse pre-allocated vectors
345    working_memory.ensure_capacity(node_count);
346    let ranks = &mut working_memory.ranks;
347    let new_ranks = &mut working_memory.new_ranks;
348
349    // initialise ranks
350    ranks[..node_count].copy_from_slice(personalisation_vector);
351
352    let teleport_factor = T::one() - damping_factor;
353
354    for _ in 0..nb_iter {
355        // compute new ranks
356        new_ranks[..node_count]
357            .par_iter_mut()
358            .enumerate()
359            .for_each(|(v, new_rank)| {
360                let teleport_prob = teleport_factor * personalisation_vector[v];
361
362                let (in_nodes, in_weights) = graph.in_edges(v);
363                let link_prob: T = in_nodes
364                    .iter()
365                    .zip(in_weights.iter())
366                    .map(|(&w, &edge_weight)| ranks[w] * edge_weight * graph.inv_out_weight[w])
367                    .sum();
368
369                *new_rank = teleport_prob + damping_factor * link_prob;
370            });
371
372        let l1: T = new_ranks[..node_count]
373            .par_iter()
374            .zip(&ranks[..node_count])
375            .map(|(new, old)| (*new - *old).abs())
376            .sum();
377
378        // Swap vectors (no allocation)
379        std::mem::swap(ranks, new_ranks);
380
381        if l1 <= tolerance {
382            break;
383        }
384    }
385
386    // Normalize (make sure that sum == 1)
387    let sum: T = ranks[..node_count].iter().cloned().sum();
388    if sum > T::zero() {
389        ranks[..node_count].iter_mut().for_each(|x| *x /= sum);
390    }
391
392    ranks[..node_count].to_vec()
393}
394
395////////////////////////////////////////
396// Constrained personalised page rank //
397////////////////////////////////////////
398
399/// Constrained parallel personalised PageRank algorithm
400///
401/// ### Params
402///
403/// * `graph` - The `AttributedGraph` with node and edge types
404/// * `damping_factor` - The dampening factor parameter, i.e., the probability
405///   of resetting.
406/// * `personalization_vector` - The vector of probabilities for the reset,
407///   making this the personalised page rank.
408/// * `nb_iter` - Maximum number of iterations for the personalised page rank.
409/// * `tol` - Optional convergence threshold on the L1 change between
410///   iterations. Defaults to `1e-12`.
411/// * `sink_node_types` - Optional HashSet of node types that act as sinks
412///   (force reset of the surfer)
413/// * `constrained_edge_types` - Optional HashSet of edge types that force reset
414///   after traversal of that edge.
415///
416///
417/// ### Returns
418///
419/// The normalised personalised PageRank scores
420pub fn constrained_personalised_page_rank<T>(
421    graph: &AttributedGraph<T>,
422    damping_factor: T,
423    personalisation_vector: &[T],
424    nb_iter: usize,
425    tol: Option<T>,
426    sink_node_types: Option<&FxHashSet<String>>,
427    constrained_edge_types: Option<&FxHashSet<String>>,
428) -> Vec<T>
429where
430    T: BixverseFloat + std::iter::Sum,
431{
432    let node_count = graph.node_types.len();
433    if node_count == 0 {
434        return vec![];
435    }
436
437    // further assertions
438    assert!(
439        T::zero() <= damping_factor && damping_factor <= T::one(),
440        "Damping factor should be between 0 and 1."
441    );
442    assert_eq!(
443        personalisation_vector.len(),
444        node_count,
445        "Personalization vector length must match node count."
446    );
447
448    let tolerance = tol.unwrap_or(T::from_f64(DEFAULT_TOLERANCE).unwrap());
449    let binding = FxHashSet::default();
450    let sink_types = sink_node_types.unwrap_or(&binding);
451    let constrained_types = constrained_edge_types.unwrap_or(&binding);
452
453    // build transition structure with constraints and weights
454    let mut out_edges: Vec<Vec<(usize, T, bool)>> = vec![Vec::new(); node_count];
455    let mut in_edges: Vec<Vec<(usize, T, bool)>> = vec![Vec::new(); node_count];
456
457    for (((&source, &target), &edge_type), &weight) in graph
458        .from
459        .iter()
460        .zip(&graph.to)
461        .zip(&graph.edge_types)
462        .zip(&graph.weights)
463    {
464        // sink nodes have no valid outgoing edges
465        if sink_types.contains(graph.node_types[source]) {
466            continue;
467        }
468
469        let is_sink_edge = constrained_types.contains(edge_type);
470        out_edges[source].push((target, weight, is_sink_edge));
471        in_edges[target].push((source, weight, is_sink_edge));
472    }
473
474    // calculate out-degrees (sum of weights of ALL edges, including sink edges)
475    let out_degrees: Vec<T> = out_edges
476        .iter()
477        .map(|edges| {
478            let total_weight: T = edges.iter().map(|(_, weight, _)| *weight).sum();
479            total_weight
480        })
481        .collect();
482
483    // track mass that can flow forward (non-sink mass) vs absorbed mass (sink mass)
484    let mut flowable_ranks: Vec<T> = personalisation_vector.to_vec();
485    let mut absorbed_ranks: Vec<T> = vec![T::zero(); node_count];
486    let teleport_factor = T::one() - damping_factor;
487
488    for _ in 0..nb_iter {
489        // Calculate new flowable and absorbed mass
490        let (new_flowable, new_absorbed): (Vec<T>, Vec<T>) = (0..node_count)
491            .into_par_iter()
492            .map(|v| {
493                let teleport_mass = teleport_factor * personalisation_vector[v];
494                let mut flowable = teleport_mass;
495                let mut absorbed = T::zero();
496
497                // zero-weight sources leak; the final normalisation restores it
498                for &(w, edge_weight, is_sink_edge) in &in_edges[v] {
499                    if out_degrees[w] > T::zero() {
500                        let flow =
501                            damping_factor * flowable_ranks[w] * edge_weight / out_degrees[w];
502
503                        if is_sink_edge {
504                            absorbed += flow;
505                        } else {
506                            flowable += flow;
507                        }
508                    }
509                }
510
511                (flowable, absorbed)
512            })
513            .unzip();
514
515        // Total absorbed mass gets teleported back
516        let total_absorbed_mass: T = new_absorbed.iter().copied().sum();
517
518        let final_flowable: Vec<T> = new_flowable
519            .into_iter()
520            .enumerate()
521            .map(|(v, flowable)| flowable + total_absorbed_mass * personalisation_vector[v])
522            .collect();
523
524        // Total ranks = flowable + absorbed
525        let total_ranks: Vec<T> = final_flowable
526            .iter()
527            .zip(&new_absorbed)
528            .map(|(f, a)| *f + *a)
529            .collect();
530
531        // Check for convergence using total ranks
532        let l1 = total_ranks
533            .par_iter()
534            .zip(flowable_ranks.par_iter().zip(&absorbed_ranks))
535            .map(|(new_total, (old_flow, old_abs))| (*new_total - (*old_flow + *old_abs)).abs())
536            .sum::<T>();
537
538        flowable_ranks = final_flowable;
539        absorbed_ranks = new_absorbed;
540
541        if l1 <= tolerance {
542            break;
543        }
544    }
545
546    // final ranks = flowable + absorbed
547    let mut ranks: Vec<T> = flowable_ranks
548        .iter()
549        .zip(&absorbed_ranks)
550        .map(|(f, a)| *f + *a)
551        .collect();
552
553    // normalise
554    let sum: T = ranks.iter().copied().sum();
555    if sum > T::zero() {
556        ranks.iter_mut().for_each(|x| *x /= sum);
557    }
558
559    ranks
560}
561
562////////////////////////////////////////////
563// Constrained page rank on a fixed graph //
564////////////////////////////////////////////
565
566/// Precomputed heterogeneous graph for repeated constrained personalised
567/// PageRank runs, i.e. the diffusion profiles of Ruiz et al.
568#[derive(Clone, Debug)]
569pub struct ConstrainedPageRankGraph<T> {
570    /// Number of nodes
571    node_count: usize,
572    /// Number of distinct node types
573    n_types: usize,
574    /// Type id per node
575    node_type: Vec<u16>,
576    /// Whether the node belongs to a sink type
577    is_sink_type: Vec<bool>,
578    /// Weight per type id. `None` uses the raw edge weights.
579    type_weights: Option<Vec<T>>,
580    /// In-edge offsets, length `node_count + 1`
581    in_indptr: Vec<usize>,
582    /// Source node per in-edge
583    in_indices: Vec<u32>,
584    /// Edge weight per in-edge
585    in_weights: Vec<T>,
586    /// Out-edge offsets, length `node_count + 1`
587    out_indptr: Vec<usize>,
588    /// Target node per out-edge
589    out_indices: Vec<u32>,
590    /// Edge weight per out-edge
591    out_weights: Vec<T>,
592    /// Transition factors without seeds, `node_count x n_types`, row-major
593    base_factors: Vec<T>,
594}
595
596impl<T> ConstrainedPageRankGraph<T>
597where
598    T: BixverseFloat + std::iter::Sum,
599{
600    /// Build the graph from 0-based edge indices and node types.
601    ///
602    /// ### Params
603    ///
604    /// * `node_types` - Type per node. Type ids follow order of first
605    ///   appearance.
606    /// * `from` - 0-based source node per edge
607    /// * `to` - 0-based target node per edge
608    /// * `weights` - Optional non-negative weight per edge. Defaults to 1.
609    /// * `type_weights` - Optional weight per node type. Must cover every type
610    ///   in `node_types`. `None` gives the plain random walk.
611    /// * `sink_types` - Node types that act as sinks (no out-flow) unless the
612    ///   node is a seed of the run. Unknown names are ignored.
613    /// * `undirected` - Add every edge in both directions.
614    ///
615    /// ### Returns
616    ///
617    /// The initialised `ConstrainedPageRankGraph`, or an error for malformed
618    /// input.
619    pub fn new(
620        node_types: &[String],
621        from: &[usize],
622        to: &[usize],
623        weights: Option<&[T]>,
624        type_weights: Option<&FxHashMap<String, T>>,
625        sink_types: &FxHashSet<String>,
626        undirected: bool,
627    ) -> Result<Self, MethodsErrors> {
628        let node_count = node_types.len();
629        if node_count == 0 {
630            return Err(CoreErrors::InvalidArgument("The graph has no nodes.".to_string()).into());
631        }
632        if node_count > u32::MAX as usize {
633            return Err(CoreErrors::InvalidArgument(format!(
634                "{node_count} nodes exceed the u32 index range."
635            ))
636            .into());
637        }
638        if to.len() != from.len() {
639            return Err(MethodsErrors::GraphLengthMismatch {
640                name: "to",
641                expected: from.len(),
642                got: to.len(),
643            });
644        }
645        if let Some(w) = weights
646            && w.len() != from.len()
647        {
648            return Err(MethodsErrors::GraphLengthMismatch {
649                name: "weights",
650                expected: from.len(),
651                got: w.len(),
652            });
653        }
654
655        // type ids in order of first appearance
656        let mut type_ids: FxHashMap<&str, u16> = FxHashMap::default();
657        let mut type_names: Vec<&str> = Vec::new();
658        let mut node_type = Vec::with_capacity(node_count);
659        for t in node_types {
660            let id = match type_ids.get(t.as_str()) {
661                Some(&id) => id,
662                None => {
663                    if type_names.len() >= u16::MAX as usize {
664                        return Err(CoreErrors::InvalidArgument(
665                            "Too many distinct node types.".to_string(),
666                        )
667                        .into());
668                    }
669                    let id = type_names.len() as u16;
670                    type_ids.insert(t.as_str(), id);
671                    type_names.push(t.as_str());
672                    id
673                }
674            };
675            node_type.push(id);
676        }
677        let n_types = type_names.len();
678
679        let type_weights = match type_weights {
680            Some(map) => {
681                let mut w = Vec::with_capacity(n_types);
682                for name in &type_names {
683                    let w_t = *map
684                        .get(*name)
685                        .ok_or_else(|| MethodsErrors::MissingNodeTypeWeight(name.to_string()))?;
686                    if !(w_t.is_finite() && w_t > T::zero()) {
687                        return Err(CoreErrors::InvalidArgument(format!(
688                            "The weight of node type '{name}' must be positive and finite."
689                        ))
690                        .into());
691                    }
692                    w.push(w_t);
693                }
694                Some(w)
695            }
696            None => None,
697        };
698
699        let is_sink_type: Vec<bool> = node_types.iter().map(|t| sink_types.contains(t)).collect();
700
701        let n_edges = if undirected {
702            2 * from.len()
703        } else {
704            from.len()
705        };
706        let mut src: Vec<u32> = Vec::with_capacity(n_edges);
707        let mut dst: Vec<u32> = Vec::with_capacity(n_edges);
708        let mut edge_w: Vec<T> = Vec::with_capacity(n_edges);
709        for (k, (&a, &b)) in from.iter().zip(to).enumerate() {
710            for (name, idx) in [("from", a), ("to", b)] {
711                if idx >= node_count {
712                    return Err(MethodsErrors::GraphIndexOutOfRange {
713                        name,
714                        index: idx,
715                        n_nodes: node_count,
716                    });
717                }
718            }
719            let w_k = weights.map_or(T::one(), |w| w[k]);
720            if !(w_k.is_finite() && w_k >= T::zero()) {
721                return Err(CoreErrors::InvalidArgument(format!(
722                    "Edge weight {k} must be non-negative and finite."
723                ))
724                .into());
725            }
726            src.push(a as u32);
727            dst.push(b as u32);
728            edge_w.push(w_k);
729            if undirected {
730                src.push(b as u32);
731                dst.push(a as u32);
732                edge_w.push(w_k);
733            }
734        }
735
736        let (out_indptr, out_indices, out_weights) = edges_to_csr(node_count, &src, &dst, &edge_w);
737        let (in_indptr, in_indices, in_weights) = edges_to_csr(node_count, &dst, &src, &edge_w);
738
739        let mut graph = Self {
740            node_count,
741            n_types,
742            node_type,
743            is_sink_type,
744            type_weights,
745            in_indptr,
746            in_indices,
747            in_weights,
748            out_indptr,
749            out_indices,
750            out_weights,
751            base_factors: Vec::new(),
752        };
753
754        let no_seeds = vec![false; node_count];
755        let mut base_factors = vec![T::zero(); node_count * n_types];
756        base_factors
757            .par_chunks_mut(n_types)
758            .enumerate()
759            .for_each(|(i, row)| graph.fill_factor_row(i, &no_seeds, row));
760        graph.base_factors = base_factors;
761
762        Ok(graph)
763    }
764
765    /// Number of nodes in the graph.
766    ///
767    /// ### Returns
768    ///
769    /// The node count.
770    pub fn node_count(&self) -> usize {
771        self.node_count
772    }
773
774    /// Compute the transition factors of one node given the current seeds.
775    ///
776    /// Non-seed nodes of a sink type get an all-zero row. Edges into seeds are
777    /// ignored, as the seeds are sources in the constrained graph.
778    ///
779    /// ### Params
780    ///
781    /// * `i` - Node index
782    /// * `seed_mask` - Whether each node is a seed of the current run
783    /// * `row` - Output slice of length `n_types`
784    fn fill_factor_row(&self, i: usize, seed_mask: &[bool], row: &mut [T]) {
785        row.fill(T::zero());
786        if self.is_sink_type[i] && !seed_mask[i] {
787            return;
788        }
789
790        for k in self.out_indptr[i]..self.out_indptr[i + 1] {
791            let j = self.out_indices[k] as usize;
792            if !seed_mask[j] {
793                row[self.node_type[j] as usize] += self.out_weights[k];
794            }
795        }
796
797        match &self.type_weights {
798            Some(w) => {
799                let denom: T = row
800                    .iter()
801                    .zip(w)
802                    .filter(|(s, _)| **s > T::zero())
803                    .map(|(_, w_t)| *w_t)
804                    .sum();
805                for (s, w_t) in row.iter_mut().zip(w) {
806                    *s = if *s > T::zero() {
807                        *w_t / (denom * *s)
808                    } else {
809                        T::zero()
810                    };
811                }
812            }
813            None => {
814                let total: T = row.iter().copied().sum();
815                let f = if total > T::zero() {
816                    T::one() / total
817                } else {
818                    T::zero()
819                };
820                row.fill(f);
821            }
822        }
823    }
824}
825
826/// Working memory for [constrained_personalised_page_rank_optimised]
827///
828/// One per thread; reused across runs to avoid per-seed allocations.
829#[derive(Debug)]
830pub struct ConstrainedPageRankWorkingMemory<T> {
831    /// Current ranks
832    ranks: Vec<T>,
833    /// Ranks of the next iteration
834    new_ranks: Vec<T>,
835    /// Transition factors for the current seeds, `node_count x n_types`
836    factors: Vec<T>,
837    /// Whether each node is a seed of the current run
838    seed_mask: Vec<bool>,
839    /// Nodes whose factor rows differ from the seed-free base
840    touched: Vec<usize>,
841}
842
843impl<T> ConstrainedPageRankWorkingMemory<T>
844where
845    T: BixverseFloat,
846{
847    /// Initialise empty working memory
848    ///
849    /// ### Returns
850    ///
851    /// Initialised `ConstrainedPageRankWorkingMemory`
852    pub fn new() -> Self {
853        Self {
854            ranks: Vec::new(),
855            new_ranks: Vec::new(),
856            factors: Vec::new(),
857            seed_mask: Vec::new(),
858            touched: Vec::new(),
859        }
860    }
861}
862
863impl<T> Default for ConstrainedPageRankWorkingMemory<T>
864where
865    T: BixverseFloat,
866{
867    fn default() -> Self {
868        Self::new()
869    }
870}
871
872/// Constrained personalised PageRank on a precomputed graph
873///
874/// Implements the diffusion profiles of Ruiz et al.: nodes with non-zero
875/// personalisation are the seeds and act as sources (in-edges dropped), while
876/// all other nodes of a sink type absorb mass (out-edges dropped). The rows of
877/// the seeds and their in-neighbours are renormalised for this run; the rest
878/// comes from the precomputed base factors.
879///
880/// The paper sends sink mass back to the seeds via `alpha * s * sum_J r_j`.
881/// That term is a scalar multiple of `s`, so letting the mass leak and
882/// normalising at the end gives the same profile. Same holds for dangling
883/// nodes.
884///
885/// Sequential over nodes: the caller is expected to parallelise over seeds.
886///
887/// ### Params
888///
889/// * `graph` - The precomputed `ConstrainedPageRankGraph`
890/// * `damping_factor` - Probability of continuing the walk (`alpha`)
891/// * `personalisation_vector` - Non-negative restart distribution over the
892///   nodes. Non-zero entries define the seeds.
893/// * `max_iter` - Maximum number of power iterations
894/// * `tolerance` - Convergence threshold on the L1 change between iterations
895/// * `working_memory` - Reusable `ConstrainedPageRankWorkingMemory`
896///
897/// ### Returns
898///
899/// The normalised diffusion profile, or an error for invalid input.
900///
901/// ### References
902///
903/// Ruiz et al., Nat Commun, 2021
904pub fn constrained_personalised_page_rank_optimised<T>(
905    graph: &ConstrainedPageRankGraph<T>,
906    damping_factor: T,
907    personalisation_vector: &[T],
908    max_iter: usize,
909    tolerance: T,
910    working_memory: &mut ConstrainedPageRankWorkingMemory<T>,
911) -> Result<Vec<T>, MethodsErrors>
912where
913    T: BixverseFloat + std::iter::Sum,
914{
915    let n = graph.node_count;
916    let nt = graph.n_types;
917
918    if personalisation_vector.len() != n {
919        return Err(MethodsErrors::GraphLengthMismatch {
920            name: "personalisation_vector",
921            expected: n,
922            got: personalisation_vector.len(),
923        });
924    }
925    if !(T::zero() <= damping_factor && damping_factor <= T::one()) {
926        return Err(CoreErrors::InvalidArgument(
927            "The damping factor must be within [0, 1].".to_string(),
928        )
929        .into());
930    }
931    if personalisation_vector
932        .iter()
933        .any(|p| !(p.is_finite() && *p >= T::zero()))
934    {
935        return Err(CoreErrors::InvalidArgument(
936            "The personalisation vector must be non-negative and finite.".to_string(),
937        )
938        .into());
939    }
940    let p_sum: T = personalisation_vector.iter().copied().sum();
941    if p_sum <= T::zero() {
942        return Err(CoreErrors::InvalidArgument(
943            "The personalisation vector sums to zero.".to_string(),
944        )
945        .into());
946    }
947
948    let ConstrainedPageRankWorkingMemory {
949        ranks,
950        new_ranks,
951        factors,
952        seed_mask,
953        touched,
954    } = working_memory;
955
956    ranks.resize(n, T::zero());
957    new_ranks.resize(n, T::zero());
958    factors.clear();
959    factors.extend_from_slice(&graph.base_factors);
960    seed_mask.clear();
961    seed_mask.extend(personalisation_vector.iter().map(|p| *p > T::zero()));
962
963    // seeds and every node with an edge into a seed get new factor rows
964    touched.clear();
965    for s in (0..n).filter(|&s| seed_mask[s]) {
966        touched.push(s);
967        let (start, end) = (graph.in_indptr[s], graph.in_indptr[s + 1]);
968        touched.extend(graph.in_indices[start..end].iter().map(|&w| w as usize));
969    }
970    for &i in touched.iter() {
971        graph.fill_factor_row(i, seed_mask, &mut factors[i * nt..(i + 1) * nt]);
972    }
973
974    ranks.copy_from_slice(personalisation_vector);
975    let teleport_factor = T::one() - damping_factor;
976
977    for _ in 0..max_iter {
978        for v in 0..n {
979            let mut rank = teleport_factor * personalisation_vector[v];
980            if !seed_mask[v] {
981                let t_v = graph.node_type[v] as usize;
982                for k in graph.in_indptr[v]..graph.in_indptr[v + 1] {
983                    let w = graph.in_indices[k] as usize;
984                    rank += damping_factor * ranks[w] * factors[w * nt + t_v] * graph.in_weights[k];
985                }
986            }
987            new_ranks[v] = rank;
988        }
989
990        let l1: T = new_ranks
991            .iter()
992            .zip(ranks.iter())
993            .map(|(a, b)| (*a - *b).abs())
994            .sum();
995
996        std::mem::swap(ranks, new_ranks);
997
998        if l1 <= tolerance {
999            break;
1000        }
1001    }
1002
1003    let sum: T = ranks.iter().copied().sum();
1004    let mut res = ranks.clone();
1005    if sum > T::zero() {
1006        res.iter_mut().for_each(|x| *x /= sum);
1007    }
1008
1009    Ok(res)
1010}
1011
1012///////////
1013// Tests //
1014///////////
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use crate::graph::graph_structures::graph_from_strings_with_attributes;
1020
1021    /// Both PPR paths match igraph's prpack on a weighted directed graph with
1022    /// a dangling node (7) and a node with only a zero-weight out-edge (6).
1023    /// Reference from `igraph::page_rank(algo = "prpack", damping = 0.8)`,
1024    /// igraph 2.3.3.
1025    #[test]
1026    fn test_page_rank_matches_igraph_prpack() {
1027        let from: Vec<usize> = vec![0, 0, 1, 1, 2, 2, 3, 3, 4, 5, 5, 6];
1028        let to: Vec<usize> = vec![1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 7, 0];
1029        let weights = vec![0.5, 1.5, 2.0, 0.3, 1.0, 0.7, 0.9, 1.2, 2.5, 0.4, 0.8, 0.0];
1030        let expected = [
1031            2.845_972_704_682_356_3e-1,
1032            5.691_945_409_364_712_6e-2,
1033            2.103_545_042_591_307_1e-1,
1034            1.049_297_762_422_016_8e-1,
1035            1.052_691_717_448_718_7e-1,
1036            1.321_832_351_066_182_5e-1,
1037            3.524_886_269_509_82e-2,
1038            7.049_772_539_019_64e-2,
1039        ];
1040        let mut p_vec = vec![0.0; 8];
1041        p_vec[0] = 1.0;
1042
1043        let graph = PageRankGraph::from_indices(8, &from, &to, Some(&weights), false);
1044
1045        let tol = DEFAULT_TOLERANCE;
1046        let optimised = personalised_page_rank_optimised(
1047            &graph,
1048            0.8,
1049            &p_vec,
1050            1000,
1051            tol,
1052            &mut PageRankWorkingMemory::new(),
1053        );
1054        let reference = personalised_page_rank(&graph, 0.8, &p_vec, 1000, None);
1055
1056        for i in 0..8 {
1057            assert!(
1058                (optimised[i] - expected[i]).abs() < 1e-10,
1059                "node {i}: optimised {} vs igraph {}",
1060                optimised[i],
1061                expected[i]
1062            );
1063            assert!(
1064                (reference[i] - expected[i]).abs() < 1e-10,
1065                "node {i}: reference {} vs igraph {}",
1066                reference[i],
1067                expected[i]
1068            );
1069        }
1070    }
1071
1072    /// On the chain a -> b -> c with a sink edge a -> b, mass reaching b is
1073    /// absorbed there and never forwarded, so c only gets what b's other
1074    /// in-flows carry: nothing. Without the sink edge c gets mass.
1075    #[test]
1076    fn test_constrained_page_rank_sink_edge_absorbs_mass() {
1077        let nodes: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1078        let node_types: Vec<String> = vec!["n".to_string(); 3];
1079        let from = vec!["a".to_string(), "b".to_string()];
1080        let to = vec!["b".to_string(), "c".to_string()];
1081        let edge_types = vec!["sink".to_string(), "flow".to_string()];
1082        let weights = vec![1.0, 1.0];
1083        let graph = graph_from_strings_with_attributes(
1084            &nodes,
1085            &node_types,
1086            &from,
1087            &to,
1088            &edge_types,
1089            &weights,
1090        );
1091        let p_vec = [1.0, 0.0, 0.0];
1092        let sink_edges: FxHashSet<String> = ["sink".to_string()].into_iter().collect();
1093
1094        let constrained = constrained_personalised_page_rank(
1095            &graph,
1096            0.85,
1097            &p_vec,
1098            1000,
1099            None,
1100            None,
1101            Some(&sink_edges),
1102        );
1103        let free = constrained_personalised_page_rank(&graph, 0.85, &p_vec, 1000, None, None, None);
1104
1105        assert!((constrained.iter().sum::<f64>() - 1.0).abs() < 1e-12);
1106        assert!(constrained[1] > 0.0);
1107        assert_eq!(constrained[2], 0.0);
1108        assert!(free[2] > 0.0);
1109    }
1110
1111    /// Toy multiscale graph: two drugs, three proteins, one function, one
1112    /// disease. Undirected edges.
1113    fn toy_multiscale() -> (Vec<String>, Vec<usize>, Vec<usize>, FxHashMap<String, f64>) {
1114        let types: Vec<String> = [
1115            "drug", "drug", "protein", "protein", "protein", "function", "disease",
1116        ]
1117        .iter()
1118        .map(|s| s.to_string())
1119        .collect();
1120        let from = vec![0, 0, 1, 2, 3, 2, 4, 6];
1121        let to = vec![2, 3, 3, 3, 4, 5, 5, 4];
1122        let type_weights: FxHashMap<String, f64> = [
1123            ("drug", 3.0),
1124            ("protein", 2.0),
1125            ("function", 1.5),
1126            ("disease", 4.0),
1127        ]
1128        .iter()
1129        .map(|(k, v)| (k.to_string(), *v))
1130        .collect();
1131        (types, from, to, type_weights)
1132    }
1133
1134    fn toy_sinks() -> FxHashSet<String> {
1135        ["drug", "disease"].iter().map(|s| s.to_string()).collect()
1136    }
1137
1138    /// The precomputed constrained PageRank matches a dense power iteration of
1139    /// eqs 1-3 in Ruiz et al., including the explicit sink teleport.
1140    #[test]
1141    fn test_constrained_optimised_matches_dense_reference() {
1142        let (types, from, to, tw) = toy_multiscale();
1143        let sinks = toy_sinks();
1144        let n = types.len();
1145        let seed = 0;
1146        let alpha = 0.85;
1147
1148        // dense G': undirected -> both directions, no in-edges to the seed, no
1149        // out-edges from other sinks
1150        let is_sink = |i: usize| sinks.contains(&types[i]) && i != seed;
1151        let mut adj = vec![vec![false; n]; n];
1152        for (&a, &b) in from.iter().zip(&to) {
1153            adj[a][b] = true;
1154            adj[b][a] = true;
1155        }
1156        for (i, row) in adj.iter_mut().enumerate() {
1157            row[seed] = false;
1158            if is_sink(i) {
1159                row.fill(false);
1160            }
1161        }
1162        let mut m = vec![vec![0.0; n]; n];
1163        for i in 0..n {
1164            let mut counts: FxHashMap<&str, f64> = FxHashMap::default();
1165            for j in (0..n).filter(|&j| adj[i][j]) {
1166                *counts.entry(types[j].as_str()).or_default() += 1.0;
1167            }
1168            let denom: f64 = counts.keys().map(|t| tw[*t]).sum();
1169            for j in (0..n).filter(|&j| adj[i][j]) {
1170                let t = types[j].as_str();
1171                m[i][j] = tw[t] / denom / counts[t];
1172            }
1173        }
1174        let mut s = vec![0.0; n];
1175        s[seed] = 1.0;
1176        let mut r = s.clone();
1177        for _ in 0..5000 {
1178            let sink_mass: f64 = (0..n).filter(|&j| is_sink(j)).map(|j| r[j]).sum();
1179            r = (0..n)
1180                .map(|v| {
1181                    let walk: f64 = (0..n).map(|i| r[i] * m[i][v]).sum();
1182                    (1.0 - alpha) * s[v] + alpha * s[v] * sink_mass + alpha * walk
1183                })
1184                .collect();
1185        }
1186        let total: f64 = r.iter().sum();
1187        r.iter_mut().for_each(|x| *x /= total);
1188
1189        let graph =
1190            ConstrainedPageRankGraph::new(&types, &from, &to, None, Some(&tw), &sinks, true)
1191                .unwrap();
1192        let mut mem = ConstrainedPageRankWorkingMemory::new();
1193        let res = constrained_personalised_page_rank_optimised(
1194            &graph, alpha, &s, 10_000, 1e-14, &mut mem,
1195        )
1196        .unwrap();
1197
1198        for (i, (a, b)) in r.iter().zip(&res).enumerate() {
1199            assert!((a - b).abs() < 1e-9, "node {i}: dense {a} vs optimised {b}");
1200        }
1201        assert!((res.iter().sum::<f64>() - 1.0).abs() < 1e-9);
1202    }
1203
1204    /// The seed of sink type still diffuses, and other sinks receive mass.
1205    #[test]
1206    fn test_constrained_seed_is_source_not_sink() {
1207        let (types, from, to, tw) = toy_multiscale();
1208        let graph =
1209            ConstrainedPageRankGraph::new(&types, &from, &to, None, Some(&tw), &toy_sinks(), true)
1210                .unwrap();
1211        let mut mem = ConstrainedPageRankWorkingMemory::new();
1212        let mut s = vec![0.0; types.len()];
1213        s[0] = 1.0;
1214        let res =
1215            constrained_personalised_page_rank_optimised(&graph, 0.85, &s, 1000, 1e-12, &mut mem)
1216                .unwrap();
1217
1218        assert!(res[0] < 0.5);
1219        assert!(res[1] > 0.0);
1220        assert!(res[6] > 0.0);
1221    }
1222
1223    /// With type weights, every non-sink row of the seed-free transition matrix
1224    /// sums to one.
1225    #[test]
1226    fn test_constrained_type_weighted_rows_sum_to_one() {
1227        let (types, from, to, tw) = toy_multiscale();
1228        let graph =
1229            ConstrainedPageRankGraph::new(&types, &from, &to, None, Some(&tw), &toy_sinks(), true)
1230                .unwrap();
1231        let nt = graph.n_types;
1232        for i in 0..graph.node_count {
1233            let row_sum: f64 = (graph.out_indptr[i]..graph.out_indptr[i + 1])
1234                .map(|k| {
1235                    let j = graph.out_indices[k] as usize;
1236                    graph.base_factors[i * nt + graph.node_type[j] as usize] * graph.out_weights[k]
1237                })
1238                .sum();
1239            let expected = if graph.is_sink_type[i] { 0.0 } else { 1.0 };
1240            assert!((row_sum - expected).abs() < 1e-12, "node {i}: {row_sum}");
1241        }
1242    }
1243
1244    /// Reusing working memory across seeds gives the same result as fresh
1245    /// memory.
1246    #[test]
1247    fn test_constrained_working_memory_reuse() {
1248        let (types, from, to, tw) = toy_multiscale();
1249        let graph =
1250            ConstrainedPageRankGraph::new(&types, &from, &to, None, Some(&tw), &toy_sinks(), true)
1251                .unwrap();
1252        let n = types.len();
1253        let mut s0 = vec![0.0; n];
1254        s0[0] = 1.0;
1255        let mut s6 = vec![0.0; n];
1256        s6[6] = 1.0;
1257
1258        let mut mem = ConstrainedPageRankWorkingMemory::new();
1259        let _ =
1260            constrained_personalised_page_rank_optimised(&graph, 0.85, &s0, 1000, 1e-12, &mut mem)
1261                .unwrap();
1262        let reused =
1263            constrained_personalised_page_rank_optimised(&graph, 0.85, &s6, 1000, 1e-12, &mut mem)
1264                .unwrap();
1265        let fresh = constrained_personalised_page_rank_optimised(
1266            &graph,
1267            0.85,
1268            &s6,
1269            1000,
1270            1e-12,
1271            &mut ConstrainedPageRankWorkingMemory::new(),
1272        )
1273        .unwrap();
1274
1275        assert_eq!(reused, fresh);
1276    }
1277
1278    /// Malformed input is rejected with an error, not a panic.
1279    #[test]
1280    fn test_constrained_rejects_bad_input() {
1281        let (types, _, _, tw) = toy_multiscale();
1282        let sinks = toy_sinks();
1283        assert!(
1284            ConstrainedPageRankGraph::new(&types, &[0], &[99], None, Some(&tw), &sinks, true)
1285                .is_err()
1286        );
1287        let mut partial = tw.clone();
1288        partial.remove("function");
1289        assert!(
1290            ConstrainedPageRankGraph::new(&types, &[0], &[2], None, Some(&partial), &sinks, true)
1291                .is_err()
1292        );
1293
1294        let graph =
1295            ConstrainedPageRankGraph::new(&types, &[0], &[2], None, Some(&tw), &sinks, true)
1296                .unwrap();
1297        let mut mem = ConstrainedPageRankWorkingMemory::new();
1298        let zeros = vec![0.0; types.len()];
1299        assert!(
1300            constrained_personalised_page_rank_optimised(&graph, 0.85, &zeros, 10, 1e-6, &mut mem)
1301                .is_err()
1302        );
1303    }
1304}