mod alias;
mod rng;
mod sgd;
mod walks;
const NOISE_TABLE_SIZE: usize = 100_000_000;
#[derive(Clone)]
pub struct Node2Vec {
embedding_dim: usize,
walk_length: usize,
num_walks: usize,
window_size: usize,
p: f64,
q: f64,
n_epochs: usize,
learning_rate: f64,
neg_samples: usize,
random_seed: u64,
}
impl Default for Node2Vec {
fn default() -> Self {
Self::new()
}
}
impl Node2Vec {
pub fn new() -> Self {
Node2Vec {
embedding_dim: 128,
walk_length: 80,
num_walks: 10,
window_size: 10,
p: 1.0,
q: 1.0,
n_epochs: 1,
learning_rate: 0.025,
neg_samples: 5,
random_seed: 42,
}
}
pub fn embedding_dim(mut self, d: usize) -> Self {
self.embedding_dim = d.max(1);
self
}
pub fn walk_length(mut self, l: usize) -> Self {
self.walk_length = l.max(1);
self
}
pub fn num_walks(mut self, n: usize) -> Self {
self.num_walks = n.max(1);
self
}
pub fn window_size(mut self, w: usize) -> Self {
self.window_size = w.max(1);
self
}
pub fn p(mut self, p: f64) -> Self {
self.p = p.max(1e-9);
self
}
pub fn q(mut self, q: f64) -> Self {
self.q = q.max(1e-9);
self
}
pub fn n_epochs(mut self, n: usize) -> Self {
self.n_epochs = n.max(1);
self
}
pub fn learning_rate(mut self, lr: f64) -> Self {
self.learning_rate = lr.max(1e-9);
self
}
pub fn neg_samples(mut self, n: usize) -> Self {
self.neg_samples = n.max(1);
self
}
pub fn random_seed(mut self, s: u64) -> Self {
self.random_seed = s;
self
}
pub fn fit(&self, n_nodes: usize, edges: &[(usize, usize)]) -> EmbedResult {
for &(u, v) in edges {
assert!(u < n_nodes, "edge node {u} >= n_nodes {n_nodes}");
assert!(v < n_nodes, "edge node {v} >= n_nodes {n_nodes}");
}
let adjacency = build_adjacency(n_nodes, edges);
let degrees: Vec<usize> = adjacency.iter().map(|nb| nb.len()).collect();
let mut rng = rng::Rng::new(self.random_seed);
let mut flat = init_embeddings(n_nodes, self.embedding_dim, &mut rng);
let all_walks = walks::generate_walks(
&adjacency,
self.walk_length,
self.num_walks,
self.p,
self.q,
&mut rng,
);
let noise_table = sgd::build_noise_table(°rees, NOISE_TABLE_SIZE);
sgd::train(
&mut flat,
&sgd::TrainParams {
n_nodes,
dim: self.embedding_dim,
walks: &all_walks,
window_size: self.window_size,
neg_samples: self.neg_samples,
n_epochs: self.n_epochs,
initial_lr: self.learning_rate,
noise_table: &noise_table,
},
&mut rng,
);
let embeddings = (0..n_nodes)
.map(|i| flat[i * self.embedding_dim..(i + 1) * self.embedding_dim].to_vec())
.collect();
EmbedResult { embeddings }
}
}
pub struct EmbedResult {
pub embeddings: Vec<Vec<f64>>,
}
fn build_adjacency(n_nodes: usize, edges: &[(usize, usize)]) -> Vec<Vec<usize>> {
let mut adjacency = vec![Vec::new(); n_nodes];
for &(u, v) in edges {
if u != v {
adjacency[u].push(v);
adjacency[v].push(u);
}
}
for nb in &mut adjacency {
nb.sort_unstable();
nb.dedup();
}
adjacency
}
fn init_embeddings(n_nodes: usize, dim: usize, rng: &mut rng::Rng) -> Vec<f64> {
let half_range = 0.5 / dim as f64;
(0..n_nodes * dim)
.map(|_| (rng.next_f64() - 0.5) * 2.0 * half_range)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn two_cliques() -> (usize, Vec<(usize, usize)>) {
let mut edges = Vec::new();
for i in 0..5 {
for j in (i + 1)..5 {
edges.push((i, j));
}
}
for i in 5..10 {
for j in (i + 1)..10 {
edges.push((i, j));
}
}
(10, edges)
}
fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a * norm_b)
}
#[test]
fn output_shape() {
let edges = vec![(0, 1), (1, 2), (2, 0)];
let result = Node2Vec::new()
.embedding_dim(16)
.num_walks(2)
.walk_length(10)
.n_epochs(1)
.fit(3, &edges);
assert_eq!(result.embeddings.len(), 3);
assert!(result.embeddings.iter().all(|e| e.len() == 16));
}
#[test]
fn isolated_node_gets_embedding() {
let edges = vec![(1, 2), (2, 3), (3, 1)];
let result = Node2Vec::new()
.embedding_dim(8)
.n_epochs(1)
.fit(4, &edges);
assert_eq!(result.embeddings.len(), 4);
for value in &result.embeddings[0] {
assert!(value.is_finite(), "isolated node embedding contains non-finite value");
}
}
#[test]
fn deterministic_with_same_seed() {
let edges = vec![(0, 1), (1, 2), (2, 3), (3, 0)];
let r1 = Node2Vec::new()
.embedding_dim(8)
.n_epochs(2)
.random_seed(77)
.fit(4, &edges);
let r2 = Node2Vec::new()
.embedding_dim(8)
.n_epochs(2)
.random_seed(77)
.fit(4, &edges);
for (a, b) in r1.embeddings.iter().zip(&r2.embeddings) {
for (x, y) in a.iter().zip(b) {
assert!((x - y).abs() < 1e-12, "embeddings differ with same seed");
}
}
}
#[test]
fn uniform_walks_produce_finite_embeddings() {
let edges = vec![(0, 1), (1, 2), (2, 0), (0, 3)];
let result = Node2Vec::new()
.embedding_dim(8)
.p(1.0)
.q(1.0)
.n_epochs(2)
.fit(4, &edges);
for embedding in &result.embeddings {
for value in embedding {
assert!(value.is_finite(), "non-finite value in p=1,q=1 embedding");
}
}
}
#[test]
fn same_clique_more_similar_than_across() {
let (n_nodes, edges) = two_cliques();
let result = Node2Vec::new()
.embedding_dim(32)
.num_walks(10)
.walk_length(20)
.n_epochs(10)
.random_seed(42)
.fit(n_nodes, &edges);
let intra_sim: f64 = {
let mut total = 0.0;
let mut count = 0;
for clique in [0..5usize, 5..10usize] {
let nodes: Vec<usize> = clique.collect();
for i in 0..nodes.len() {
for j in (i + 1)..nodes.len() {
total += cosine_similarity(
&result.embeddings[nodes[i]],
&result.embeddings[nodes[j]],
);
count += 1;
}
}
}
total / count as f64
};
let inter_sim: f64 = {
let mut total = 0.0;
let mut count = 0;
for i in 0..5 {
for j in 5..10 {
total += cosine_similarity(&result.embeddings[i], &result.embeddings[j]);
count += 1;
}
}
total / count as f64
};
assert!(
intra_sim > inter_sim,
"intra-clique similarity ({intra_sim:.4}) should exceed inter-clique ({inter_sim:.4})"
);
}
}