Skip to main content

rust_igraph/algorithms/properties/
link_prediction.rs

1//! Link prediction heuristic scores (ALGO-TR-013).
2//!
3//! Classical topology-based link prediction features that score vertex pairs
4//! by their structural proximity. These are widely used as baselines in link
5//! prediction benchmarks and as edge features in GNN models.
6//!
7//! Implements:
8//! - Common Neighbors (CN)
9//! - Adamic-Adar Index (AA)
10//! - Resource Allocation Index (RA)
11//! - Preferential Attachment (PA)
12//! - Jaccard Coefficient
13
14#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
15
16use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
17
18/// Compute Common Neighbors score for given vertex pairs.
19///
20/// For each pair `(u, v)`, returns `|N(u) ∩ N(v)|` — the number of
21/// shared neighbors.
22///
23/// # Examples
24///
25/// ```
26/// use rust_igraph::{Graph, link_pred_common_neighbors};
27///
28/// let g = Graph::from_edges(
29///     &[(0,1),(0,2),(1,2),(1,3),(2,3)], false, Some(4)
30/// ).unwrap();
31/// let scores = link_pred_common_neighbors(&g, &[(0, 3)]).unwrap();
32/// // Vertices 0 and 3 share neighbors 1 and 2
33/// assert_eq!(scores[0], 2.0);
34/// ```
35pub fn link_pred_common_neighbors(
36    graph: &Graph,
37    pairs: &[(VertexId, VertexId)],
38) -> IgraphResult<Vec<f64>> {
39    validate_pairs(graph, pairs)?;
40    let mut scores = Vec::with_capacity(pairs.len());
41
42    for &(u, v) in pairs {
43        let nu = neighbor_set(graph, u)?;
44        let nv = neighbor_set(graph, v)?;
45        let cn = count_intersection(&nu, &nv);
46        scores.push(cn as f64);
47    }
48
49    Ok(scores)
50}
51
52/// Compute Adamic-Adar Index for given vertex pairs.
53///
54/// For each pair `(u, v)`, returns `Σ_{w ∈ N(u) ∩ N(v)} 1/log(deg(w))`.
55/// Weights common neighbors by the inverse log of their degree, giving
56/// more importance to less-connected shared neighbors.
57///
58/// # Examples
59///
60/// ```
61/// use rust_igraph::{Graph, link_pred_adamic_adar};
62///
63/// let g = Graph::from_edges(
64///     &[(0,1),(0,2),(1,2),(1,3),(2,3)], false, Some(4)
65/// ).unwrap();
66/// let scores = link_pred_adamic_adar(&g, &[(0, 3)]).unwrap();
67/// // Common neighbors of 0,3 are {1,2}; deg(1)=3, deg(2)=3
68/// // AA = 1/log(3) + 1/log(3)
69/// let expected = 2.0 / 3.0_f64.ln();
70/// assert!((scores[0] - expected).abs() < 1e-10);
71/// ```
72pub fn link_pred_adamic_adar(
73    graph: &Graph,
74    pairs: &[(VertexId, VertexId)],
75) -> IgraphResult<Vec<f64>> {
76    validate_pairs(graph, pairs)?;
77    let nv = graph.vcount() as usize;
78
79    let mut degrees = Vec::with_capacity(nv);
80    for v in 0..nv {
81        degrees.push(graph.degree(v as VertexId)?);
82    }
83
84    let mut scores = Vec::with_capacity(pairs.len());
85
86    for &(u, v) in pairs {
87        let nu = neighbor_set(graph, u)?;
88        let nv_set = neighbor_set(graph, v)?;
89        let mut score = 0.0;
90        for &w in &nu {
91            if nv_set.contains(&w) {
92                let deg = degrees[w as usize];
93                if deg > 1 {
94                    score += 1.0 / (deg as f64).ln();
95                }
96            }
97        }
98        scores.push(score);
99    }
100
101    Ok(scores)
102}
103
104/// Compute Resource Allocation Index for given vertex pairs.
105///
106/// For each pair `(u, v)`, returns `Σ_{w ∈ N(u) ∩ N(v)} 1/deg(w)`.
107/// Similar to Adamic-Adar but uses inverse degree instead of inverse
108/// log-degree, penalizing high-degree common neighbors more strongly.
109///
110/// # Examples
111///
112/// ```
113/// use rust_igraph::{Graph, link_pred_resource_allocation};
114///
115/// let g = Graph::from_edges(
116///     &[(0,1),(0,2),(1,2),(1,3),(2,3)], false, Some(4)
117/// ).unwrap();
118/// let scores = link_pred_resource_allocation(&g, &[(0, 3)]).unwrap();
119/// // Common neighbors of 0,3 are {1,2}; deg(1)=3, deg(2)=3
120/// // RA = 1/3 + 1/3 = 2/3
121/// assert!((scores[0] - 2.0 / 3.0).abs() < 1e-10);
122/// ```
123pub fn link_pred_resource_allocation(
124    graph: &Graph,
125    pairs: &[(VertexId, VertexId)],
126) -> IgraphResult<Vec<f64>> {
127    validate_pairs(graph, pairs)?;
128    let nv = graph.vcount() as usize;
129
130    let mut degrees = Vec::with_capacity(nv);
131    for v in 0..nv {
132        degrees.push(graph.degree(v as VertexId)?);
133    }
134
135    let mut scores = Vec::with_capacity(pairs.len());
136
137    for &(u, v) in pairs {
138        let nu = neighbor_set(graph, u)?;
139        let nv_set = neighbor_set(graph, v)?;
140        let mut score = 0.0;
141        for &w in &nu {
142            if nv_set.contains(&w) {
143                let deg = degrees[w as usize];
144                if deg > 0 {
145                    score += 1.0 / deg as f64;
146                }
147            }
148        }
149        scores.push(score);
150    }
151
152    Ok(scores)
153}
154
155/// Compute Preferential Attachment score for given vertex pairs.
156///
157/// For each pair `(u, v)`, returns `deg(u) × deg(v)`. Based on the
158/// preferential attachment model where high-degree nodes are more
159/// likely to form new connections.
160///
161/// # Examples
162///
163/// ```
164/// use rust_igraph::{Graph, link_pred_preferential_attachment};
165///
166/// let g = Graph::from_edges(
167///     &[(0,1),(0,2),(1,2),(1,3),(2,3)], false, Some(4)
168/// ).unwrap();
169/// let scores = link_pred_preferential_attachment(&g, &[(0, 3)]).unwrap();
170/// // deg(0)=2, deg(3)=2 → PA = 4
171/// assert_eq!(scores[0], 4.0);
172/// ```
173pub fn link_pred_preferential_attachment(
174    graph: &Graph,
175    pairs: &[(VertexId, VertexId)],
176) -> IgraphResult<Vec<f64>> {
177    validate_pairs(graph, pairs)?;
178
179    let mut scores = Vec::with_capacity(pairs.len());
180
181    for &(u, v) in pairs {
182        let du = graph.degree(u)?;
183        let dv = graph.degree(v)?;
184        scores.push((du * dv) as f64);
185    }
186
187    Ok(scores)
188}
189
190/// Compute Jaccard Coefficient for given vertex pairs.
191///
192/// For each pair `(u, v)`, returns `|N(u) ∩ N(v)| / |N(u) ∪ N(v)|`.
193/// Returns 0.0 if both vertices have no neighbors.
194///
195/// # Examples
196///
197/// ```
198/// use rust_igraph::{Graph, link_pred_jaccard};
199///
200/// let g = Graph::from_edges(
201///     &[(0,1),(0,2),(1,2),(1,3),(2,3)], false, Some(4)
202/// ).unwrap();
203/// let scores = link_pred_jaccard(&g, &[(0, 3)]).unwrap();
204/// // N(0)={1,2}, N(3)={1,2}. Intersection={1,2}, Union={1,2}
205/// // Jaccard = 2/2 = 1.0
206/// assert!((scores[0] - 1.0).abs() < 1e-10);
207/// ```
208pub fn link_pred_jaccard(graph: &Graph, pairs: &[(VertexId, VertexId)]) -> IgraphResult<Vec<f64>> {
209    validate_pairs(graph, pairs)?;
210    let mut scores = Vec::with_capacity(pairs.len());
211
212    for &(u, v) in pairs {
213        let nu = neighbor_set(graph, u)?;
214        let nv = neighbor_set(graph, v)?;
215        let intersection = count_intersection(&nu, &nv);
216        let union_size = nu.len() + nv.len() - intersection;
217        let score = if union_size == 0 {
218            0.0
219        } else {
220            intersection as f64 / union_size as f64
221        };
222        scores.push(score);
223    }
224
225    Ok(scores)
226}
227
228// --- Internal helpers ---
229
230fn validate_pairs(graph: &Graph, pairs: &[(VertexId, VertexId)]) -> IgraphResult<()> {
231    let n = graph.vcount();
232    for &(u, v) in pairs {
233        if u >= n {
234            return Err(IgraphError::VertexOutOfRange { id: u, n });
235        }
236        if v >= n {
237            return Err(IgraphError::VertexOutOfRange { id: v, n });
238        }
239    }
240    Ok(())
241}
242
243fn neighbor_set(graph: &Graph, v: VertexId) -> IgraphResult<Vec<VertexId>> {
244    graph.neighbors(v)
245}
246
247fn count_intersection(a: &[VertexId], b: &[VertexId]) -> usize {
248    // For small neighbor lists, linear scan is fine
249    let mut count = 0;
250    for &x in a {
251        if b.contains(&x) {
252            count += 1;
253        }
254    }
255    count
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn diamond() -> Graph {
263        // 0-1, 0-2, 1-2, 1-3, 2-3
264        Graph::from_edges(&[(0, 1), (0, 2), (1, 2), (1, 3), (2, 3)], false, Some(4)).unwrap()
265    }
266
267    fn star5() -> Graph {
268        // 0 connected to 1,2,3,4
269        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
270    }
271
272    // --- common neighbors ---
273
274    #[test]
275    fn cn_basic() {
276        let g = diamond();
277        let scores = link_pred_common_neighbors(&g, &[(0, 3)]).unwrap();
278        assert!((scores[0] - 2.0).abs() < 1e-10); // {1, 2}
279    }
280
281    #[test]
282    fn cn_adjacent_pair() {
283        let g = diamond();
284        let scores = link_pred_common_neighbors(&g, &[(0, 1)]).unwrap();
285        assert!((scores[0] - 1.0).abs() < 1e-10); // {2}
286    }
287
288    #[test]
289    fn cn_no_common() {
290        let g = star5();
291        let scores = link_pred_common_neighbors(&g, &[(1, 2)]).unwrap();
292        assert!((scores[0] - 1.0).abs() < 1e-10); // {0} is the common neighbor
293    }
294
295    #[test]
296    fn cn_disconnected_pair() {
297        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
298        let scores = link_pred_common_neighbors(&g, &[(0, 2)]).unwrap();
299        assert!(scores[0].abs() < 1e-10);
300    }
301
302    #[test]
303    fn cn_multiple_pairs() {
304        let g = diamond();
305        let scores = link_pred_common_neighbors(&g, &[(0, 3), (0, 1), (1, 3)]).unwrap();
306        assert_eq!(scores.len(), 3);
307    }
308
309    #[test]
310    fn cn_empty_pairs() {
311        let g = diamond();
312        let scores = link_pred_common_neighbors(&g, &[]).unwrap();
313        assert!(scores.is_empty());
314    }
315
316    #[test]
317    fn cn_invalid_vertex() {
318        let g = diamond();
319        assert!(link_pred_common_neighbors(&g, &[(0, 10)]).is_err());
320    }
321
322    // --- adamic adar ---
323
324    #[test]
325    fn aa_basic() {
326        let g = diamond();
327        let scores = link_pred_adamic_adar(&g, &[(0, 3)]).unwrap();
328        // Common neighbors {1,2}; deg(1)=3, deg(2)=3
329        let expected = 2.0 / 3.0_f64.ln();
330        assert!((scores[0] - expected).abs() < 1e-10);
331    }
332
333    #[test]
334    fn aa_no_common() {
335        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
336        let scores = link_pred_adamic_adar(&g, &[(0, 2)]).unwrap();
337        assert!(scores[0].abs() < 1e-10);
338    }
339
340    #[test]
341    fn aa_degree_one_neighbor() {
342        // If common neighbor has degree 1, log(1) = 0 → skipped
343        let g = Graph::from_edges(&[(0, 2), (1, 2)], false, Some(3)).unwrap();
344        let scores = link_pred_adamic_adar(&g, &[(0, 1)]).unwrap();
345        // Common neighbor is 2 with degree 2: 1/log(2)
346        let expected = 1.0 / 2.0_f64.ln();
347        assert!((scores[0] - expected).abs() < 1e-10);
348    }
349
350    // --- resource allocation ---
351
352    #[test]
353    fn ra_basic() {
354        let g = diamond();
355        let scores = link_pred_resource_allocation(&g, &[(0, 3)]).unwrap();
356        // Common neighbors {1,2}; deg(1)=3, deg(2)=3
357        let expected = 2.0 / 3.0;
358        assert!((scores[0] - expected).abs() < 1e-10);
359    }
360
361    #[test]
362    fn ra_no_common() {
363        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
364        let scores = link_pred_resource_allocation(&g, &[(0, 2)]).unwrap();
365        assert!(scores[0].abs() < 1e-10);
366    }
367
368    // --- preferential attachment ---
369
370    #[test]
371    fn pa_basic() {
372        let g = diamond();
373        let scores = link_pred_preferential_attachment(&g, &[(0, 3)]).unwrap();
374        // deg(0)=2, deg(3)=2
375        assert!((scores[0] - 4.0).abs() < 1e-10);
376    }
377
378    #[test]
379    fn pa_star_center() {
380        let g = star5();
381        let scores = link_pred_preferential_attachment(&g, &[(1, 2)]).unwrap();
382        // deg(1)=1, deg(2)=1
383        assert!((scores[0] - 1.0).abs() < 1e-10);
384    }
385
386    #[test]
387    fn pa_isolated() {
388        let g = Graph::with_vertices(3);
389        let scores = link_pred_preferential_attachment(&g, &[(0, 1)]).unwrap();
390        assert!(scores[0].abs() < 1e-10);
391    }
392
393    // --- jaccard ---
394
395    #[test]
396    fn jaccard_perfect_overlap() {
397        let g = diamond();
398        let scores = link_pred_jaccard(&g, &[(0, 3)]).unwrap();
399        // N(0)={1,2}, N(3)={1,2}. Jaccard = 2/2 = 1.0
400        assert!((scores[0] - 1.0).abs() < 1e-10);
401    }
402
403    #[test]
404    fn jaccard_partial_overlap() {
405        let g = diamond();
406        let scores = link_pred_jaccard(&g, &[(0, 1)]).unwrap();
407        // N(0)={1,2}, N(1)={0,2,3}. Intersection={2}, Union={0,1,2,3}
408        // Jaccard = 1/4 = 0.25
409        assert!((scores[0] - 0.25).abs() < 1e-10);
410    }
411
412    #[test]
413    fn jaccard_no_overlap() {
414        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
415        let scores = link_pred_jaccard(&g, &[(0, 2)]).unwrap();
416        assert!(scores[0].abs() < 1e-10);
417    }
418
419    #[test]
420    fn jaccard_both_isolated() {
421        let g = Graph::with_vertices(3);
422        let scores = link_pred_jaccard(&g, &[(0, 1)]).unwrap();
423        assert!(scores[0].abs() < 1e-10);
424    }
425}