Skip to main content

rune_node2vec/
lib.rs

1//! Node2Vec — graph node embeddings via biased random walks and skip-gram.
2//!
3//! Node2Vec (Grover & Leskovec, 2016) learns a continuous vector representation for
4//! every node in an undirected graph. It generalises DeepWalk by introducing two
5//! hyperparameters (`p` and `q`) that bias the random walk to explore either the
6//! local neighbourhood (BFS-like) or the wider graph (DFS-like), letting the
7//! embedding capture both structural equivalence and community membership.
8//!
9//! The algorithm has two phases:
10//!
11//! 1. **Biased random walks** — for each node, generate `num_walks` walks of length
12//!    `walk_length`. At each step the transition probability is weighted by `1/p`
13//!    (return to previous), `1.0` (common neighbour), or `1/q` (exploration), and
14//!    sampled in O(1) with the alias method.
15//!
16//! 2. **Skip-gram with negative sampling** — treats each walk as a sentence, pairs
17//!    each node with its context window, and optimises embeddings with SGD. Negative
18//!    nodes are drawn proportional to `degree^(3/4)`.
19//!
20//! # Features
21//!
22//! - Pure Rust — no unsafe code, no dependencies beyond the library itself
23//! - Deterministic output via `random_seed`
24//! - Supports isolated nodes (walk length 1; embedding is random-initialised)
25//! - p=1, q=1 reproduces standard DeepWalk behaviour
26//! - Fluent builder API matching the rest of the `rune-*` family
27//!
28//! # Quick Start
29//!
30//! ```rust
31//! use rune_node2vec::Node2Vec;
32//!
33//! // A triangle: nodes 0-1-2-0
34//! let edges = vec![(0, 1), (1, 2), (2, 0)];
35//! let result = Node2Vec::new()
36//!     .embedding_dim(8)
37//!     .n_epochs(5)
38//!     .fit(3, &edges);
39//!
40//! assert_eq!(result.embeddings.len(), 3);
41//! assert_eq!(result.embeddings[0].len(), 8);
42//! ```
43//!
44//! # CLI
45//!
46//! ```bash
47//! rune-node2vec graph.edgelist --dim 64 --epochs 5
48//! cat graph.edgelist | rune-node2vec -
49//! ```
50
51mod alias;
52mod rng;
53mod sgd;
54mod walks;
55
56const NOISE_TABLE_SIZE: usize = 100_000_000;
57
58/// Builder for configuring and running the Node2Vec algorithm.
59///
60/// Construct with [`Node2Vec::new`], chain optional parameters, then call [`Node2Vec::fit`].
61///
62/// # Example
63///
64/// ```rust
65/// use rune_node2vec::Node2Vec;
66///
67/// let model = Node2Vec::new()
68///     .embedding_dim(64)
69///     .walk_length(40)
70///     .num_walks(5)
71///     .p(1.0)
72///     .q(0.5)
73///     .n_epochs(3)
74///     .random_seed(7);
75/// ```
76#[derive(Clone)]
77pub struct Node2Vec {
78    embedding_dim: usize,
79    walk_length: usize,
80    num_walks: usize,
81    window_size: usize,
82    p: f64,
83    q: f64,
84    n_epochs: usize,
85    learning_rate: f64,
86    neg_samples: usize,
87    random_seed: u64,
88}
89
90impl Default for Node2Vec {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl Node2Vec {
97    /// Creates a new Node2Vec instance with sensible defaults.
98    ///
99    /// | Parameter | Default |
100    /// |---|---|
101    /// | `embedding_dim` | 128 |
102    /// | `walk_length` | 80 |
103    /// | `num_walks` | 10 |
104    /// | `window_size` | 10 |
105    /// | `p` | 1.0 |
106    /// | `q` | 1.0 |
107    /// | `n_epochs` | 1 |
108    /// | `learning_rate` | 0.025 |
109    /// | `neg_samples` | 5 |
110    /// | `random_seed` | 42 |
111    ///
112    /// # Example
113    ///
114    /// ```rust
115    /// use rune_node2vec::Node2Vec;
116    ///
117    /// let model = Node2Vec::new();
118    /// ```
119    pub fn new() -> Self {
120        Node2Vec {
121            embedding_dim: 128,
122            walk_length: 80,
123            num_walks: 10,
124            window_size: 10,
125            p: 1.0,
126            q: 1.0,
127            n_epochs: 1,
128            learning_rate: 0.025,
129            neg_samples: 5,
130            random_seed: 42,
131        }
132    }
133
134    /// Dimensionality of each output embedding vector. Defaults to `128`.
135    ///
136    /// # Example
137    ///
138    /// ```rust
139    /// use rune_node2vec::Node2Vec;
140    ///
141    /// let model = Node2Vec::new().embedding_dim(64);
142    /// ```
143    pub fn embedding_dim(mut self, d: usize) -> Self {
144        self.embedding_dim = d.max(1);
145        self
146    }
147
148    /// Number of nodes in each random walk. Defaults to `80`.
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use rune_node2vec::Node2Vec;
154    ///
155    /// let model = Node2Vec::new().walk_length(40);
156    /// ```
157    pub fn walk_length(mut self, l: usize) -> Self {
158        self.walk_length = l.max(1);
159        self
160    }
161
162    /// Number of walks generated from each node. Defaults to `10`.
163    ///
164    /// # Example
165    ///
166    /// ```rust
167    /// use rune_node2vec::Node2Vec;
168    ///
169    /// let model = Node2Vec::new().num_walks(5);
170    /// ```
171    pub fn num_walks(mut self, n: usize) -> Self {
172        self.num_walks = n.max(1);
173        self
174    }
175
176    /// Skip-gram context window half-width. Defaults to `10`.
177    ///
178    /// Each centre node is paired with all nodes within `window_size` positions
179    /// on either side in the walk.
180    ///
181    /// # Example
182    ///
183    /// ```rust
184    /// use rune_node2vec::Node2Vec;
185    ///
186    /// let model = Node2Vec::new().window_size(5);
187    /// ```
188    pub fn window_size(mut self, w: usize) -> Self {
189        self.window_size = w.max(1);
190        self
191    }
192
193    /// Return parameter controlling the likelihood of revisiting a node. Defaults to `1.0`.
194    ///
195    /// Low `p` encourages the walk to backtrack; high `p` pushes the walk forward.
196    /// Clamped to a minimum of `1e-9`.
197    ///
198    /// # Example
199    ///
200    /// ```rust
201    /// use rune_node2vec::Node2Vec;
202    ///
203    /// let model = Node2Vec::new().p(0.5);
204    /// ```
205    pub fn p(mut self, p: f64) -> Self {
206        self.p = p.max(1e-9);
207        self
208    }
209
210    /// In-out parameter controlling the walk's tendency to explore. Defaults to `1.0`.
211    ///
212    /// Low `q` favours DFS-like exploration of the graph; high `q` favours BFS-like
213    /// local neighbourhood traversal. Clamped to a minimum of `1e-9`.
214    ///
215    /// # Example
216    ///
217    /// ```rust
218    /// use rune_node2vec::Node2Vec;
219    ///
220    /// let model = Node2Vec::new().q(2.0);
221    /// ```
222    pub fn q(mut self, q: f64) -> Self {
223        self.q = q.max(1e-9);
224        self
225    }
226
227    /// Number of training epochs over all walks. Defaults to `1`.
228    ///
229    /// # Example
230    ///
231    /// ```rust
232    /// use rune_node2vec::Node2Vec;
233    ///
234    /// let model = Node2Vec::new().n_epochs(5);
235    /// ```
236    pub fn n_epochs(mut self, n: usize) -> Self {
237        self.n_epochs = n.max(1);
238        self
239    }
240
241    /// Initial SGD learning rate; decays linearly to `0.0001 × initial_lr`. Defaults to `0.025`.
242    ///
243    /// # Example
244    ///
245    /// ```rust
246    /// use rune_node2vec::Node2Vec;
247    ///
248    /// let model = Node2Vec::new().learning_rate(0.01);
249    /// ```
250    pub fn learning_rate(mut self, lr: f64) -> Self {
251        self.learning_rate = lr.max(1e-9);
252        self
253    }
254
255    /// Number of negative samples drawn per positive (centre, context) pair. Defaults to `5`.
256    ///
257    /// # Example
258    ///
259    /// ```rust
260    /// use rune_node2vec::Node2Vec;
261    ///
262    /// let model = Node2Vec::new().neg_samples(10);
263    /// ```
264    pub fn neg_samples(mut self, n: usize) -> Self {
265        self.neg_samples = n.max(1);
266        self
267    }
268
269    /// Seed for the internal Xorshift64 PRNG. Identical seeds produce identical embeddings.
270    /// Defaults to `42`.
271    ///
272    /// # Example
273    ///
274    /// ```rust
275    /// use rune_node2vec::Node2Vec;
276    ///
277    /// let model = Node2Vec::new().random_seed(123);
278    /// ```
279    pub fn random_seed(mut self, s: u64) -> Self {
280        self.random_seed = s;
281        self
282    }
283
284    /// Computes Node2Vec embeddings for an undirected graph.
285    ///
286    /// `n_nodes` is the total number of nodes (indices `0..n_nodes`). `edges` is a
287    /// slice of undirected `(u, v)` pairs; each edge is added in both directions.
288    /// Self-loops are ignored.
289    ///
290    /// Every node receives an embedding regardless of whether it has any edges.
291    /// Isolated nodes are not visited during walk training and keep their random
292    /// initial embedding.
293    ///
294    /// # Panics
295    ///
296    /// Panics if any node index in `edges` is `≥ n_nodes`.
297    ///
298    /// # Example
299    ///
300    /// ```rust
301    /// use rune_node2vec::Node2Vec;
302    ///
303    /// // Two connected triangles sharing no edge.
304    /// let edges = vec![
305    ///     (0, 1), (1, 2), (2, 0),
306    ///     (3, 4), (4, 5), (5, 3),
307    /// ];
308    /// let result = Node2Vec::new()
309    ///     .embedding_dim(16)
310    ///     .num_walks(5)
311    ///     .walk_length(20)
312    ///     .n_epochs(3)
313    ///     .random_seed(42)
314    ///     .fit(6, &edges);
315    ///
316    /// assert_eq!(result.embeddings.len(), 6);
317    /// assert!(result.embeddings.iter().all(|e| e.len() == 16));
318    /// ```
319    pub fn fit(&self, n_nodes: usize, edges: &[(usize, usize)]) -> EmbedResult {
320        for &(u, v) in edges {
321            assert!(u < n_nodes, "edge node {u} >= n_nodes {n_nodes}");
322            assert!(v < n_nodes, "edge node {v} >= n_nodes {n_nodes}");
323        }
324
325        let adjacency = build_adjacency(n_nodes, edges);
326        let degrees: Vec<usize> = adjacency.iter().map(|nb| nb.len()).collect();
327
328        let mut rng = rng::Rng::new(self.random_seed);
329
330        let mut flat = init_embeddings(n_nodes, self.embedding_dim, &mut rng);
331
332        let all_walks = walks::generate_walks(
333            &adjacency,
334            self.walk_length,
335            self.num_walks,
336            self.p,
337            self.q,
338            &mut rng,
339        );
340
341        let noise_table = sgd::build_noise_table(&degrees, NOISE_TABLE_SIZE);
342
343        sgd::train(
344            &mut flat,
345            &sgd::TrainParams {
346                n_nodes,
347                dim: self.embedding_dim,
348                walks: &all_walks,
349                window_size: self.window_size,
350                neg_samples: self.neg_samples,
351                n_epochs: self.n_epochs,
352                initial_lr: self.learning_rate,
353                noise_table: &noise_table,
354            },
355            &mut rng,
356        );
357
358        let embeddings = (0..n_nodes)
359            .map(|i| flat[i * self.embedding_dim..(i + 1) * self.embedding_dim].to_vec())
360            .collect();
361
362        EmbedResult { embeddings }
363    }
364}
365
366/// The output of a completed Node2Vec run.
367pub struct EmbedResult {
368    /// One embedding vector per node, each of length `embedding_dim`.
369    pub embeddings: Vec<Vec<f64>>,
370}
371
372/// Builds a sorted adjacency list from undirected edges (self-loops excluded).
373fn build_adjacency(n_nodes: usize, edges: &[(usize, usize)]) -> Vec<Vec<usize>> {
374    let mut adjacency = vec![Vec::new(); n_nodes];
375    for &(u, v) in edges {
376        if u != v {
377            adjacency[u].push(v);
378            adjacency[v].push(u);
379        }
380    }
381    for nb in &mut adjacency {
382        nb.sort_unstable();
383        nb.dedup();
384    }
385    adjacency
386}
387
388/// Initialises embeddings uniformly in `[-0.5/dim, 0.5/dim]`.
389fn init_embeddings(n_nodes: usize, dim: usize, rng: &mut rng::Rng) -> Vec<f64> {
390    let half_range = 0.5 / dim as f64;
391    (0..n_nodes * dim)
392        .map(|_| (rng.next_f64() - 0.5) * 2.0 * half_range)
393        .collect()
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn two_cliques() -> (usize, Vec<(usize, usize)>) {
401        // Clique A: nodes 0-4, Clique B: nodes 5-9.
402        let mut edges = Vec::new();
403        for i in 0..5 {
404            for j in (i + 1)..5 {
405                edges.push((i, j));
406            }
407        }
408        for i in 5..10 {
409            for j in (i + 1)..10 {
410                edges.push((i, j));
411            }
412        }
413        (10, edges)
414    }
415
416    fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
417        let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
418        let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
419        let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
420        if norm_a == 0.0 || norm_b == 0.0 {
421            return 0.0;
422        }
423        dot / (norm_a * norm_b)
424    }
425
426    #[test]
427    fn output_shape() {
428        let edges = vec![(0, 1), (1, 2), (2, 0)];
429        let result = Node2Vec::new()
430            .embedding_dim(16)
431            .num_walks(2)
432            .walk_length(10)
433            .n_epochs(1)
434            .fit(3, &edges);
435        assert_eq!(result.embeddings.len(), 3);
436        assert!(result.embeddings.iter().all(|e| e.len() == 16));
437    }
438
439    #[test]
440    fn isolated_node_gets_embedding() {
441        // Node 0 has no edges.
442        let edges = vec![(1, 2), (2, 3), (3, 1)];
443        let result = Node2Vec::new()
444            .embedding_dim(8)
445            .n_epochs(1)
446            .fit(4, &edges);
447        assert_eq!(result.embeddings.len(), 4);
448        // Isolated node should have a finite (non-NaN, non-inf) embedding.
449        for value in &result.embeddings[0] {
450            assert!(value.is_finite(), "isolated node embedding contains non-finite value");
451        }
452    }
453
454    #[test]
455    fn deterministic_with_same_seed() {
456        let edges = vec![(0, 1), (1, 2), (2, 3), (3, 0)];
457        let r1 = Node2Vec::new()
458            .embedding_dim(8)
459            .n_epochs(2)
460            .random_seed(77)
461            .fit(4, &edges);
462        let r2 = Node2Vec::new()
463            .embedding_dim(8)
464            .n_epochs(2)
465            .random_seed(77)
466            .fit(4, &edges);
467        for (a, b) in r1.embeddings.iter().zip(&r2.embeddings) {
468            for (x, y) in a.iter().zip(b) {
469                assert!((x - y).abs() < 1e-12, "embeddings differ with same seed");
470            }
471        }
472    }
473
474    #[test]
475    fn uniform_walks_produce_finite_embeddings() {
476        // p=1, q=1 is standard DeepWalk.
477        let edges = vec![(0, 1), (1, 2), (2, 0), (0, 3)];
478        let result = Node2Vec::new()
479            .embedding_dim(8)
480            .p(1.0)
481            .q(1.0)
482            .n_epochs(2)
483            .fit(4, &edges);
484        for embedding in &result.embeddings {
485            for value in embedding {
486                assert!(value.is_finite(), "non-finite value in p=1,q=1 embedding");
487            }
488        }
489    }
490
491    #[test]
492    fn same_clique_more_similar_than_across() {
493        let (n_nodes, edges) = two_cliques();
494        let result = Node2Vec::new()
495            .embedding_dim(32)
496            .num_walks(10)
497            .walk_length(20)
498            .n_epochs(10)
499            .random_seed(42)
500            .fit(n_nodes, &edges);
501
502        // Average intra-clique cosine similarity.
503        let intra_sim: f64 = {
504            let mut total = 0.0;
505            let mut count = 0;
506            for clique in [0..5usize, 5..10usize] {
507                let nodes: Vec<usize> = clique.collect();
508                for i in 0..nodes.len() {
509                    for j in (i + 1)..nodes.len() {
510                        total += cosine_similarity(
511                            &result.embeddings[nodes[i]],
512                            &result.embeddings[nodes[j]],
513                        );
514                        count += 1;
515                    }
516                }
517            }
518            total / count as f64
519        };
520
521        // Average inter-clique cosine similarity.
522        let inter_sim: f64 = {
523            let mut total = 0.0;
524            let mut count = 0;
525            for i in 0..5 {
526                for j in 5..10 {
527                    total += cosine_similarity(&result.embeddings[i], &result.embeddings[j]);
528                    count += 1;
529                }
530            }
531            total / count as f64
532        };
533
534        assert!(
535            intra_sim > inter_sim,
536            "intra-clique similarity ({intra_sim:.4}) should exceed inter-clique ({inter_sim:.4})"
537        );
538    }
539}