Algorithm Search⌘K

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

Read the full page about node2vec