Algorithm Search⌘K

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
deepwalk.pyRawGitHub
# 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

Read the full page about DeepWalk