DeepWalk in Python
Learns node embeddings by applying skip-gram to random-walk sequences.
- File
deepwalk.py, 104 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/deepwalk/. The authors are listed in prov/0SCSY9.jsonl.
import math
import random
def random_walk(adj: list[list[int]], start: int, t: int, rng: random.Random) -> list[int]:
"""A walk of at most t vertices from start; each step goes to a uniform out-neighbour.
The walk stops early at a vertex with no out-neighbour.
"""
walk = [start]
while len(walk) < t and adj[walk[-1]]:
walk.append(rng.choice(adj[walk[-1]]))
return walk
def window_pairs(walk: list[int], w: int) -> list[tuple[int, int]]:
"""The skip-gram pairs (centre, context) of a walk, with a window of w on each side."""
pairs = []
for j in range(len(walk)):
for k in range(max(0, j - w), min(len(walk), j + w + 1)):
if k != j:
pairs.append((walk[j], walk[k]))
return pairs
def tree_path(n: int, u: int) -> list[tuple[int, int]]:
"""Inner nodes and signs from the root to the leaf of u in a complete binary tree.
The tree has the n vertices as leaves in heap layout: inner nodes 0 .. n-2,
the leaf of u is node n - 1 + u. The sign is +1 when the path goes to the left
child and -1 when it goes to the right child.
"""
node = n - 1 + u
path = []
while node > 0:
parent = (node - 1) // 2
path.append((parent, 1 if node == 2 * parent + 1 else -1))
node = parent
path.reverse()
return path
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 dot(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b))
def hs_probability(phi_v: list[float], psi: list[list[float]], n: int, u: int) -> float:
"""Pr(u | v) under the hierarchical softmax: the product of the branch probabilities."""
p = 1.0
for node, sign in tree_path(n, u):
p *= sigmoid(sign * dot(psi[node], phi_v))
return p
def deepwalk(adj: list[list[int]], d: int = 8, w: int = 2, gamma: int = 10, t: int = 10,
alpha: float = 0.05, seed: int = 0) -> list[list[float]]:
"""Return a d-dimensional embedding of each vertex, learned from random walks."""
return deepwalk_with_tree(adj, d, w, gamma, t, alpha, seed)[0]
def deepwalk_with_tree(adj: list[list[int]], d: int = 8, w: int = 2, gamma: int = 10, t: int = 10,
alpha: float = 0.05, seed: int = 0) -> tuple[list[list[float]], list[list[float]]]:
"""Return the embedding phi and the vectors psi of the inner nodes of the tree.
adj[v] lists the out-neighbours of v (both directions for an undirected
graph). Each of gamma passes starts one walk of at most t vertices at every
vertex, in a random order; each pair of vertices at most w apart in a walk is
one step of stochastic gradient descent on the skip-gram loss with a
hierarchical softmax. The run is deterministic for a fixed seed.
"""
n = len(adj)
rng = random.Random(seed)
phi = [[(rng.random() - 0.5) / d for _ in range(d)] for _ in range(n)] # @line L1
psi = [[0.0] * d for _ in range(max(n - 1, 0))]
for _ in range(gamma): # @line L2
order = list(range(n)) # @line L3
rng.shuffle(order)
for v in order: # @line L4
walk = random_walk(adj, v, t, rng) # @line L5
for j in range(len(walk)): # @line L6
x = phi[walk[j]]
for k in range(max(0, j - w), min(len(walk), j + w + 1)): # @line L7
if k == j:
continue
grad = [0.0] * d # @line L8: the loss is -log Pr(walk[k] | x)
for node, sign in tree_path(n, walk[k]):
y = psi[node]
g = alpha * (1.0 - sigmoid(sign * dot(y, x))) * sign
for i in range(d): # @line L9: gradient steps on x and on the path
grad[i] += g * y[i]
y[i] += g * x[i]
for i in range(d):
x[i] += grad[i]
return phi, psi # @line L10
test_deepwalk.py 107 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/deepwalk/. The authors are listed in prov/0SCSY9.jsonl.
import math
import random
from deepwalk import deepwalk, deepwalk_with_tree, dot, hs_probability, random_walk, tree_path, window_pairs
def undirected(n: int, edges: list[tuple[int, int]]) -> list[list[int]]:
adj: list[list[int]] = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
return adj
def cosine(a: list[float], b: list[float]) -> float:
return dot(a, b) / math.sqrt(dot(a, a) * dot(b, b))
def pairs_oracle(walk: list[int], w: int) -> list[tuple[int, int]]:
out = []
for j, a in enumerate(walk):
for k, b in enumerate(walk):
if 0 < abs(j - k) <= w:
out.append((a, b))
return out
def loss(adj: list[list[int]], phi: list[list[float]], psi: list[list[float]], walks: list[list[int]], w: int) -> float:
n = len(adj)
return -sum(math.log(hs_probability(phi[a], psi, n, b)) for walk in walks for a, b in window_pairs(walk, w))
EXAMPLE = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4), (4, 5), (4, 6), (5, 6)]
def test_example() -> None:
adj = undirected(7, EXAMPLE)
phi = deepwalk(adj, d=8, w=2, gamma=20, t=10, alpha=0.05, seed=0)
assert phi == deepwalk(adj, d=8, w=2, gamma=20, t=10, alpha=0.05, seed=0)
for a, b in [(0, 1), (1, 2), (5, 6), (4, 5)]:
assert cosine(phi[a], phi[b]) > 0.5
for a, b in [(0, 5), (1, 6), (0, 6)]:
assert cosine(phi[a], phi[b]) < 0.0
def test_edges() -> None:
assert deepwalk([]) == []
rng = random.Random(3)
assert random_walk([[]], 0, 5, rng) == [0]
assert random_walk([[1], [2], []], 0, 10, rng) == [0, 1, 2]
assert window_pairs([7], 3) == []
assert window_pairs([1, 2, 3], 1) == [(1, 2), (2, 1), (2, 3), (3, 2)]
assert tree_path(1, 0) == []
assert tree_path(4, 0) == [(0, 1), (1, 1)]
assert tree_path(4, 3) == [(0, -1), (2, -1)]
# an isolated vertex keeps its initial vector
a = deepwalk([[1], [0], []], d=4, gamma=3, seed=9)
b = deepwalk([[1], [0], []], d=4, gamma=0, seed=9)
assert a[2] == b[2]
def test_training_lowers_the_loss() -> None:
adj = undirected(7, EXAMPLE)
rng = random.Random(11)
walks = [random_walk(adj, v, 10, rng) for v in range(7) for _ in range(5)]
phi0, psi0 = deepwalk_with_tree(adj, d=8, gamma=0, seed=2)
pairs = sum(len(window_pairs(wk, 2)) for wk in walks)
before = loss(adj, phi0, psi0, walks, 2)
# with all inner-node vectors 0, each branch has probability 1/2
assert abs(before - sum(len(tree_path(7, b)) for wk in walks for _, b in window_pairs(wk, 2)) * math.log(2)) < 1e-9
phi1, psi1 = deepwalk_with_tree(adj, d=8, gamma=20, seed=2)
after = loss(adj, phi1, psi1, walks, 2)
assert after < 0.9 * before
assert pairs > 0
def test_random_against_oracle() -> None:
rng = random.Random(2014)
for _ in range(600):
n = rng.randint(1, 10)
directed = rng.random() < 0.5
adj: list[list[int]] = [[] for _ in range(n)]
for _ in range(rng.randint(0, 2 * n)):
u, v = rng.randrange(n), rng.randrange(n)
if u == v:
continue
adj[u].append(v)
if not directed:
adj[v].append(u)
t = rng.randint(1, 8)
start = rng.randrange(n)
walk = random_walk(adj, start, t, rng)
assert walk[0] == start and 1 <= len(walk) <= t
assert all(walk[i + 1] in adj[walk[i]] for i in range(len(walk) - 1))
assert len(walk) == t or not adj[walk[-1]]
w = rng.randint(1, 4)
assert sorted(window_pairs(walk, w)) == sorted(pairs_oracle(walk, w))
d = rng.randint(1, 5)
phi_v = [rng.uniform(-2, 2) for _ in range(d)]
psi = [[rng.uniform(-2, 2) for _ in range(d)] for _ in range(max(n - 1, 0))]
total = sum(hs_probability(phi_v, psi, n, u) for u in range(n))
assert abs(total - 1.0) < 1e-9
leaves = {tuple(tree_path(n, u)) for u in range(n)}
assert len(leaves) == n
What the next level needs
- the tests did not run in this build