node2vec in Python
Biased random walks with return and in-out parameters, then skip-gram.
- File
node2vec.py, 195 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/node2vec/. The authors are listed in prov/3SC38Y.jsonl.
import math
import random
def build_graph(
n: int, edges: list[tuple[int, int] | tuple[int, int, float]]
) -> list[dict[int, float]]:
"""Return the adjacency maps of an undirected weighted graph on 0 .. n-1.
Each edge is (u, v) with weight 1 or (u, v, w) with w > 0. A repeated edge
adds its weight to the earlier one. Self-loops are not allowed.
"""
if n < 0:
raise ValueError("n must be non-negative")
adj: list[dict[int, float]] = [{} for _ in range(n)]
for e in edges:
u, v = e[0], e[1]
w = float(e[2]) if len(e) == 3 else 1.0
if not (0 <= u < n and 0 <= v < n):
raise ValueError("edge endpoint out of range")
if u == v:
raise ValueError("self-loops are not allowed")
if not w > 0.0:
raise ValueError("edge weights must be positive")
adj[u][v] = adj[u].get(v, 0.0) + w
adj[v][u] = adj[v].get(u, 0.0) + w
return adj
def transition_weights(
adj: list[dict[int, float]], t: int, v: int, p: float, q: float
) -> list[tuple[int, float]]:
"""Return the unnormalized weights of the next step after the step t -> v.
The weight of a neighbour x of v is w(v, x) / p if x = t, w(v, x) if x is
a neighbour of t, and w(v, x) / q otherwise.
"""
out = []
for x, w in adj[v].items(): # @line L1
if x == t:
out.append((x, w / p))
elif x in adj[t]:
out.append((x, w))
else:
out.append((x, w / q))
return out
def _choose(items: list[tuple[int, float]], rng: random.Random) -> int:
total = sum(w for _, w in items)
r = rng.random() * total
acc = 0.0
for x, w in items:
acc += w
if r < acc:
return x
return items[-1][0]
def node2vec_walk(
adj: list[dict[int, float]],
start: int,
length: int,
p: float,
q: float,
rng: random.Random,
) -> list[int]:
"""Return one biased walk with `length` vertices that starts at `start`.
The first step goes to a neighbour chosen with probability proportional
to the edge weight; later steps use transition_weights. A walk from an
isolated vertex is just [start].
"""
walk = [start] # @line L5
if length > 1 and adj[start]:
walk.append(_choose(list(adj[start].items()), rng))
while 1 < len(walk) < length: # @line L6
t, v = walk[-2], walk[-1]
walk.append(_choose(transition_weights(adj, t, v, p, q), rng)) # @line L7
return walk
def _sigmoid(x: float) -> float:
if x >= 0:
return 1.0 / (1.0 + math.exp(-x))
e = math.exp(x)
return e / (1.0 + e)
def skipgram(
n: int,
walks: list[list[int]],
dim: int,
window: int,
negatives: int,
epochs: int,
rate: float,
rng: random.Random,
) -> list[list[float]]:
"""Train skip-gram with negative sampling on the walks by plain SGD.
For each pair (u, c) with c within `window` positions of u in a walk, the
loss is -log s(z_u . y_c) - sum over `negatives` sampled vertices m of
-log s(-z_u . y_m), where s is the logistic function and negatives are
drawn with probability proportional to count ** 0.75.
"""
z = [[(rng.random() - 0.5) / dim for _ in range(dim)] for _ in range(n)]
y = [[0.0] * dim for _ in range(n)]
count = [0] * n
for walk in walks:
for u in walk:
count[u] += 1
pool = [u for u in range(n) if count[u] > 0]
if not pool:
return z
cum = []
acc = 0.0
for u in pool:
acc += count[u] ** 0.75
cum.append(acc)
def draw() -> int:
r = rng.random() * acc
lo, hi = 0, len(cum) - 1
while lo < hi:
mid = (lo + hi) // 2
if cum[mid] > r:
hi = mid
else:
lo = mid + 1
return pool[lo]
pairs_total = max(1, epochs * sum(len(w) for w in walks))
done = 0
for _ in range(epochs):
for walk in walks:
for i, u in enumerate(walk):
lr = max(rate * 1e-4, rate * (1.0 - done / pairs_total))
done += 1
lo, hi = max(0, i - window), min(len(walk), i + window + 1)
for j in range(lo, hi):
if j == i:
continue
zu = z[u]
grad = [0.0] * dim
targets = [(walk[j], 1.0)] + [(draw(), 0.0) for _ in range(negatives)]
for c, label in targets:
yc = y[c]
g = lr * (label - _sigmoid(sum(a * b for a, b in zip(zu, yc))))
for k in range(dim):
grad[k] += g * yc[k]
yc[k] += g * zu[k]
for k in range(dim):
zu[k] += grad[k]
return z
def node2vec(
n: int,
edges: list[tuple[int, int] | tuple[int, int, float]],
dim: int = 16,
walks_per_vertex: int = 10,
length: int = 20,
window: int = 5,
p: float = 1.0,
q: float = 1.0,
negatives: int = 5,
epochs: int = 1,
rate: float = 0.025,
seed: int = 0,
) -> list[list[float]]:
"""Return a dim-dimensional vector for each vertex of an undirected graph.
The graph has vertices 0 .. n-1 and edges (u, v) or (u, v, w) with w > 0.
The return parameter p and the in-out parameter q must be positive. The
result depends only on the inputs and the seed.
"""
if dim < 1 or walks_per_vertex < 0 or length < 1 or window < 1 or negatives < 0:
raise ValueError("invalid size parameter")
if not (p > 0.0 and q > 0.0):
raise ValueError("p and q must be positive")
adj = build_graph(n, edges)
rng = random.Random(seed)
walks: list[list[int]] = [] # @line L2
for _ in range(walks_per_vertex): # @line L3
order = list(range(n))
rng.shuffle(order)
for u in order: # @line L4
walk = node2vec_walk(adj, u, length, p, q, rng)
walks.append(walk) # @line L8
z = skipgram(n, walks, dim, window, negatives, epochs, rate, rng) # @line L9
return z # @line L10
test_node2vec.py 152 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/node2vec/. The authors are listed in prov/3SC38Y.jsonl.
import math
import random
from collections import deque
from node2vec import build_graph, node2vec, node2vec_walk, transition_weights
def distance(adj: list[dict[int, float]], s: int, t: int) -> int:
"""Return the number of edges on a shortest path from s to t, or -1."""
dist = {s: 0}
queue = deque([s])
while queue:
u = queue.popleft()
for v in adj[u]:
if v not in dist:
dist[v] = dist[u] + 1
queue.append(v)
return dist.get(t, -1)
def oracle(adj, t, v, p, q):
"""The definition of Grover and Leskovec: alpha = 1/p, 1, 1/q for d(t, x) = 0, 1, 2."""
out = {}
for x, w in adj[v].items():
d = distance(adj, t, x)
assert d in (0, 1, 2)
alpha = {0: 1.0 / p, 1: 1.0, 2: 1.0 / q}[d]
out[x] = w * alpha
return out
def random_graph(rng: random.Random, n: int, prob: float, weighted: bool):
edges = []
for u in range(n):
for v in range(u + 1, n):
if rng.random() < prob:
edges.append((u, v, rng.choice([0.5, 1.0, 2.0, 3.0])) if weighted else (u, v))
return edges
def test_worked_example() -> None:
adj = build_graph(6, [(0, 1), (0, 2), (1, 2), (1, 3), (3, 4), (3, 5), (4, 5)])
for p, q, want in ((1.0, 2.0, {0: 0.4, 2: 0.4, 3: 0.2}), (4.0, 0.5, {0: 1 / 13, 2: 4 / 13, 3: 8 / 13})):
w = dict(transition_weights(adj, 0, 1, p, q))
total = sum(w.values())
for x, pr in want.items():
assert abs(w[x] / total - pr) < 1e-12
def test_edge_cases() -> None:
rng = random.Random(1)
adj = build_graph(3, [(0, 1)])
assert node2vec_walk(adj, 2, 10, 1.0, 1.0, rng) == [2]
assert node2vec_walk(adj, 0, 1, 1.0, 1.0, rng) == [0]
assert node2vec_walk(adj, 0, 6, 0.3, 7.0, rng) == [0, 1, 0, 1, 0, 1]
assert node2vec(0, [], dim=3) == []
z = node2vec(3, [(0, 1)], dim=4, walks_per_vertex=2, length=5, seed=3)
assert len(z) == 3 and all(len(row) == 4 for row in z)
assert z == node2vec(3, [(0, 1)], dim=4, walks_per_vertex=2, length=5, seed=3)
for bad in (dict(p=0.0), dict(q=-1.0), dict(dim=0), dict(length=0)):
try:
node2vec(2, [(0, 1)], **bad)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
for edges in ([(0, 0)], [(0, 5)], [(0, 1, 0.0)]):
try:
build_graph(2, edges)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
def test_random_weights_against_definition() -> None:
rng = random.Random(2016)
for _ in range(500):
n = rng.randint(2, 8)
edges = random_graph(rng, n, rng.random(), rng.random() < 0.5)
adj = build_graph(n, edges)
p = rng.choice([0.25, 0.5, 1.0, 2.0, 4.0])
q = rng.choice([0.25, 0.5, 1.0, 2.0, 4.0])
for t in range(n):
for v in adj[t]:
got = dict(transition_weights(adj, t, v, p, q))
want = oracle(adj, t, v, p, q)
assert got.keys() == want.keys()
for x in want:
assert abs(got[x] - want[x]) < 1e-12
if p == q == 1.0:
assert all(abs(got[x] - adj[v][x]) < 1e-12 for x in got)
def test_random_walks_are_valid() -> None:
rng = random.Random(55)
for _ in range(500):
n = rng.randint(1, 8)
adj = build_graph(n, random_graph(rng, n, rng.random(), True))
start = rng.randrange(n)
length = rng.randint(1, 12)
walk = node2vec_walk(adj, start, length, rng.choice([0.5, 1.0, 2.0]), rng.choice([0.5, 1.0, 2.0]), rng)
assert walk[0] == start
assert len(walk) == (length if adj[start] else 1)
for a, b in zip(walk, walk[1:]):
assert b in adj[a]
def test_step_frequencies() -> None:
rng = random.Random(9)
for _ in range(40):
n = rng.randint(3, 7)
adj = build_graph(n, random_graph(rng, n, 0.6, True))
arcs = [(t, v) for t in range(n) for v in adj[t]]
if not arcs:
continue
t, v = rng.choice(arcs)
p, q = rng.choice([0.5, 2.0]), rng.choice([0.5, 2.0])
want = oracle(adj, t, v, p, q)
total = sum(want.values())
trials = 6000
seen = {x: 0 for x in want}
for _ in range(trials):
w = node2vec_walk_from(adj, t, v, p, q, rng)
seen[w] += 1
for x in want:
pr = want[x] / total
assert abs(seen[x] / trials - pr) < 5 * math.sqrt(pr * (1 - pr) / trials) + 1e-9
def node2vec_walk_from(adj, t, v, p, q, rng):
"""Sample the vertex after the step t -> v through node2vec_walk."""
while True:
walk = node2vec_walk(adj, t, 3, p, q, rng)
if walk[1] == v:
return walk[2]
def test_embedding_separates_cliques() -> None:
edges = [(i, j) for i in range(5) for j in range(i + 1, 5)]
edges += [(i, j) for i in range(5, 10) for j in range(i + 1, 10)] + [(4, 5)]
z = node2vec(10, edges, dim=8, walks_per_vertex=10, length=20, window=3, seed=1)
def cos(a, b):
return sum(x * y for x, y in zip(a, b)) / math.sqrt(sum(x * x for x in a) * sum(y * y for y in b))
within = [cos(z[i], z[j]) for i in range(10) for j in range(i + 1, 10) if (i < 5) == (j < 5)]
between = [cos(z[i], z[j]) for i in range(5) for j in range(5, 10)]
assert sum(within) / len(within) > sum(between) / len(between) + 0.3
What the next level needs
- the tests did not run in this build