Skip to main content

rust_igraph/algorithms/properties/
rwpe.rs

1//! Random Walk Positional Encoding (ALGO-TR-010).
2//!
3//! Computes the diagonal entries of powers of the random walk transition
4//! matrix `P = D⁻¹A`, where `D` is the degree matrix and `A` is the
5//! adjacency matrix. For each vertex `v`, the k-th entry is the probability
6//! that a random walk of length `k` starting at `v` returns to `v`.
7//!
8//! Used as positional encodings in Graph Transformers (e.g., `GraphGPS`,
9//! `SAN`, `SignNet`) as a structural characterization of each node's local
10//! topology.
11
12#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
13
14use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
15
16/// Compute Random Walk Positional Encoding for all vertices.
17///
18/// For each vertex, returns a vector of `k_steps` values where entry `i`
19/// is `P(walk of length i+1 returns to start)`. This is the diagonal of
20/// `(D⁻¹A)^(i+1)`.
21///
22/// # Parameters
23///
24/// - `graph` — The input graph (undirected).
25/// - `k_steps` — Number of walk lengths to compute (1 through `k_steps`).
26///
27/// # Returns
28///
29/// A `Vec<Vec<f64>>` of shape `[vcount][k_steps]`. Entry `[v][k]` is the
30/// return probability for vertex `v` at walk length `k+1`.
31///
32/// # Examples
33///
34/// ```
35/// use rust_igraph::{Graph, rwpe};
36///
37/// // Triangle: each vertex has degree 2, connected to the other two.
38/// // At step 1: from any vertex, probability of return is 0 (must leave).
39/// // At step 2: from any vertex, each neighbor has prob 0.5 to go back.
40/// //   P(return at step 2) = 0.5 * 0.5 + 0.5 * 0.5 = 0.5
41/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
42/// let pe = rwpe(&g, 2).unwrap();
43/// assert_eq!(pe.len(), 3);
44/// assert!((pe[0][0] - 0.0).abs() < 1e-10); // step 1: no self-loop
45/// assert!((pe[0][1] - 0.5).abs() < 1e-10); // step 2: return prob = 0.5
46/// ```
47pub fn rwpe(graph: &Graph, k_steps: usize) -> IgraphResult<Vec<Vec<f64>>> {
48    let nv = graph.vcount() as usize;
49
50    if k_steps == 0 {
51        return Ok(vec![Vec::new(); nv]);
52    }
53
54    if graph.is_directed() {
55        return Err(IgraphError::InvalidArgument(
56            "rwpe is defined for undirected graphs only".to_string(),
57        ));
58    }
59
60    // Compute degrees and transition probabilities
61    let mut degrees: Vec<usize> = Vec::with_capacity(nv);
62    for v in 0..nv {
63        degrees.push(graph.degree(v as VertexId)?);
64    }
65
66    // For each vertex, compute return probabilities via explicit
67    // probability propagation (sparse matrix-vector product).
68    let mut result: Vec<Vec<f64>> = Vec::with_capacity(nv);
69
70    for src in 0..nv {
71        let mut pe = Vec::with_capacity(k_steps);
72
73        // prob[v] = probability that the walk is currently at vertex v
74        let mut prob: Vec<f64> = vec![0.0; nv];
75        prob[src] = 1.0;
76
77        for _ in 0..k_steps {
78            let mut next_prob: Vec<f64> = vec![0.0; nv];
79
80            for v in 0..nv {
81                if prob[v] == 0.0 {
82                    continue;
83                }
84                let deg = degrees[v];
85                if deg == 0 {
86                    // Isolated vertex: walk stays in place (absorbing)
87                    next_prob[v] += prob[v];
88                    continue;
89                }
90                let transition = prob[v] / deg as f64;
91                let neighbors = graph.neighbors(v as VertexId)?;
92                for &nei in &neighbors {
93                    next_prob[nei as usize] += transition;
94                }
95            }
96
97            pe.push(next_prob[src]);
98            prob = next_prob;
99        }
100
101        result.push(pe);
102    }
103
104    Ok(result)
105}
106
107/// Compute RWPE only for specified vertices (more efficient for batches).
108///
109/// # Examples
110///
111/// ```
112/// use rust_igraph::{Graph, rwpe_vertices};
113///
114/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3),(3,0)], false, Some(4)).unwrap();
115/// let pe = rwpe_vertices(&g, &[0, 2], 3).unwrap();
116/// assert_eq!(pe.len(), 2);
117/// assert_eq!(pe[0].len(), 3);
118/// ```
119pub fn rwpe_vertices(
120    graph: &Graph,
121    vertices: &[VertexId],
122    k_steps: usize,
123) -> IgraphResult<Vec<Vec<f64>>> {
124    let nv = graph.vcount() as usize;
125
126    if k_steps == 0 {
127        return Ok(vec![Vec::new(); vertices.len()]);
128    }
129
130    if graph.is_directed() {
131        return Err(IgraphError::InvalidArgument(
132            "rwpe is defined for undirected graphs only".to_string(),
133        ));
134    }
135
136    for &v in vertices {
137        if v >= graph.vcount() {
138            return Err(IgraphError::VertexOutOfRange {
139                id: v,
140                n: graph.vcount(),
141            });
142        }
143    }
144
145    let mut degrees: Vec<usize> = Vec::with_capacity(nv);
146    for v in 0..nv {
147        degrees.push(graph.degree(v as VertexId)?);
148    }
149
150    let mut result: Vec<Vec<f64>> = Vec::with_capacity(vertices.len());
151
152    for &src in vertices {
153        let mut pe = Vec::with_capacity(k_steps);
154        let mut prob: Vec<f64> = vec![0.0; nv];
155        prob[src as usize] = 1.0;
156
157        for _ in 0..k_steps {
158            let mut next_prob: Vec<f64> = vec![0.0; nv];
159
160            for v in 0..nv {
161                if prob[v] == 0.0 {
162                    continue;
163                }
164                let deg = degrees[v];
165                if deg == 0 {
166                    next_prob[v] += prob[v];
167                    continue;
168                }
169                let transition = prob[v] / deg as f64;
170                let neighbors = graph.neighbors(v as VertexId)?;
171                for &nei in &neighbors {
172                    next_prob[nei as usize] += transition;
173                }
174            }
175
176            pe.push(next_prob[src as usize]);
177            prob = next_prob;
178        }
179
180        result.push(pe);
181    }
182
183    Ok(result)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn triangle() -> Graph {
191        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
192    }
193
194    fn cycle4() -> Graph {
195        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
196    }
197
198    fn path3() -> Graph {
199        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
200    }
201
202    #[test]
203    fn triangle_step1_no_return() {
204        let g = triangle();
205        let pe = rwpe(&g, 1).unwrap();
206        for row in &pe {
207            assert!((row[0] - 0.0).abs() < 1e-10);
208        }
209    }
210
211    #[test]
212    fn triangle_step2_return() {
213        let g = triangle();
214        let pe = rwpe(&g, 2).unwrap();
215        for row in &pe {
216            assert!((row[1] - 0.5).abs() < 1e-10);
217        }
218    }
219
220    #[test]
221    fn cycle4_return_probs() {
222        let g = cycle4();
223        let pe = rwpe(&g, 4).unwrap();
224        // In 4-cycle, each vertex has degree 2.
225        // Step 1: cannot return (no self-loops)
226        assert!((pe[0][0] - 0.0).abs() < 1e-10);
227        // Step 2: from vertex 0, go to 1 or 3 (prob 0.5 each).
228        //   From 1: prob 0.5 to go back to 0. From 3: prob 0.5 to go to 0.
229        //   Return = 0.5*0.5 + 0.5*0.5 = 0.5
230        assert!((pe[0][1] - 0.5).abs() < 1e-10);
231        // Step 3: cannot return (odd length, bipartite cycle)
232        assert!((pe[0][2] - 0.0).abs() < 1e-10);
233        // Step 4: should be positive
234        assert!(pe[0][3] > 0.0);
235    }
236
237    #[test]
238    fn isolated_vertex() {
239        let g = Graph::with_vertices(3);
240        let pe = rwpe(&g, 3).unwrap();
241        for row in &pe {
242            for &val in row {
243                assert!((val - 1.0).abs() < 1e-10);
244            }
245        }
246    }
247
248    #[test]
249    fn path_endpoints_differ() {
250        let g = path3(); // 0-1-2
251        let pe = rwpe(&g, 3).unwrap();
252        // Vertex 0 (degree 1) vs vertex 1 (degree 2) should differ
253        assert_ne!(pe[0], pe[1]);
254        // Endpoints should be symmetric
255        assert_eq!(pe[0], pe[2]);
256    }
257
258    #[test]
259    fn zero_steps() {
260        let g = triangle();
261        let pe = rwpe(&g, 0).unwrap();
262        assert_eq!(pe.len(), 3);
263        for row in &pe {
264            assert!(row.is_empty());
265        }
266    }
267
268    #[test]
269    fn directed_error() {
270        let g = Graph::from_edges(&[(0, 1), (1, 2)], true, Some(3)).unwrap();
271        assert!(rwpe(&g, 2).is_err());
272    }
273
274    #[test]
275    fn rwpe_vertices_subset() {
276        let g = cycle4();
277        let full = rwpe(&g, 3).unwrap();
278        let subset = rwpe_vertices(&g, &[0, 2], 3).unwrap();
279        assert_eq!(subset.len(), 2);
280        assert_eq!(subset[0], full[0]);
281        assert_eq!(subset[1], full[2]);
282    }
283
284    #[test]
285    fn rwpe_vertices_invalid() {
286        let g = cycle4();
287        assert!(rwpe_vertices(&g, &[10], 2).is_err());
288    }
289
290    #[test]
291    fn probabilities_bounded() {
292        let g = cycle4();
293        let pe = rwpe(&g, 10).unwrap();
294        for row in &pe {
295            for &val in row {
296                assert!(val >= 0.0);
297                assert!(val <= 1.0);
298            }
299        }
300    }
301
302    #[test]
303    fn all_vertices_same_in_regular_graph() {
304        let g = cycle4();
305        let pe = rwpe(&g, 5).unwrap();
306        for row in pe.iter().skip(1) {
307            for (k, &val) in row.iter().enumerate() {
308                assert!((pe[0][k] - val).abs() < 1e-10);
309            }
310        }
311    }
312}