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(°rees, 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}