Algorithm Search⌘K

Walktrap algorithm in Python

Agglomerative clustering where nodes close in short random-walk distance merge.

File
walktrap_algorithm.py, 113 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
walktrap_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/walktrap-algorithm/. The authors are listed in prov/78KW1X.jsonl.

import math


def modularity(
    n: int, edges: list[tuple[int, int, float]], parts: list[list[int]]
) -> float:
    """Return the modularity of a partition of an undirected weighted graph."""
    total = sum(w for _, _, w in edges)
    if total == 0:
        return 0.0
    label = [0] * n
    for c, part in enumerate(parts):
        for v in part:
            label[v] = c
    inside = [0.0] * len(parts)
    degree = [0.0] * len(parts)
    for u, v, w in edges:
        degree[label[u]] += w
        degree[label[v]] += w
        if label[u] == label[v]:
            inside[label[u]] += w
    return sum(inside[c] / total - (degree[c] / (2 * total)) ** 2 for c in range(len(parts)))


def walktrap(
    n: int,
    edges: list[tuple[int, int] | tuple[int, int, float]],
    t: int = 4,
) -> tuple[list[list[int]], list[tuple[int, int, float]]]:
    """Cluster an undirected graph by the Walktrap method of Pons and Latapy.

    The vertices are 0 .. n-1 and each edge is (u, v) with weight 1 or
    (u, v, w) with w > 0; self-loops in the input are not allowed. Every
    vertex gets an extra loop of weight 1. Walks have t steps.

    Returns (parts, merges): parts is the partition with the largest
    modularity among those of the dendrogram (the earliest one if several
    tie), each part sorted, parts sorted by smallest vertex; merges lists
    (a, b, delta_sigma) in merge order, where the community created by the
    k-th merge (counting from 0) gets the id n + k.
    """
    if n < 0 or t < 1:
        raise ValueError("n must be non-negative and t positive")
    wedges: list[tuple[int, int, float]] = []
    a: list[dict[int, float]] = [{v: 1.0} for v in range(n)]  # @line L1
    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) or u == v or not w > 0.0:
            raise ValueError("bad edge")
        wedges.append((u, v, w))
        a[u][v] = a[u].get(v, 0.0) + w
        a[v][u] = a[v].get(u, 0.0) + w
    d = [sum(a[v].values()) for v in range(n)]
    vec: dict[int, list[float]] = {}
    for i in range(n):  # @line L2
        row = [0.0] * n
        row[i] = 1.0
        for _ in range(t):
            nxt = [0.0] * n
            for x in range(n):
                if row[x]:
                    share = row[x] / d[x]
                    for y, w in a[x].items():
                        nxt[y] += share * w
            row = nxt
        vec[i] = [row[k] / math.sqrt(d[k]) for k in range(n)]
    size = {i: 1 for i in range(n)}
    members = {i: [i] for i in range(n)}
    nbr: dict[int, set[int]] = {i: {j for j in a[i] if j != i} for i in range(n)}

    def cost(c1: int, c2: int) -> float:
        s1, s2 = size[c1], size[c2]
        dist2 = sum((x - y) ** 2 for x, y in zip(vec[c1], vec[c2]))
        return s1 * s2 / (s1 + s2) * dist2 / n

    delta: dict[tuple[int, int], float] = {}
    for i in range(n):  # @line L3
        for j in nbr[i]:
            if i < j:
                delta[(i, j)] = cost(i, j)
    parts_seq = [[[i] for i in range(n)]]  # @line L4
    merges: list[tuple[int, int, float]] = []
    while delta:  # @line L5
        best = min(delta.values())
        tol = 1e-12 * max(1.0, abs(best))
        c1, c2 = min(p for p, v in delta.items() if v <= best + tol)  # @line L6
        c3 = n + len(merges)
        merges.append((c1, c2, delta[(c1, c2)]))
        s1, s2 = size[c1], size[c2]
        vec[c3] = [(s1 * x + s2 * y) / (s1 + s2) for x, y in zip(vec[c1], vec[c2])]  # @line L7
        size[c3] = s1 + s2
        members[c3] = members[c1] + members[c2]
        nbr[c3] = (nbr[c1] | nbr[c2]) - {c1, c2}
        for c in (c1, c2):
            for x in nbr[c]:
                nbr[x].discard(c)
                delta.pop((min(c, x), max(c, x)), None)
            del vec[c], size[c], members[c], nbr[c]
        for x in nbr[c3]:  # @line L8
            nbr[x].add(c3)
            delta[(x, c3)] = cost(x, c3)
        parts_seq.append([sorted(m) for m in members.values()])  # @line L9
    best_q = -math.inf
    best_parts: list[list[int]] = []
    for parts in parts_seq:  # @line L10
        q = modularity(n, wedges, parts)
        if q > best_q + 1e-12:
            best_q, best_parts = q, parts
    return sorted(best_parts), merges
test_walktrap_algorithm.py 115 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/walktrap-algorithm/. The authors are listed in prov/78KW1X.jsonl.

import itertools
import random
from fractions import Fraction

from walktrap_algorithm import modularity, walktrap


def oracle(n, edges, t):
    """Walktrap from the definitions, in exact arithmetic.

    Each merge joins the two adjacent communities whose union increases
    sigma = (1/n) sum over communities C and i in C of r(i, C)^2 the least,
    with sigma computed from scratch; ties go to the smallest pair of ids.
    """
    a = [[Fraction(0)] * n for _ in range(n)]
    for i in range(n):
        a[i][i] = Fraction(1)
    for e in edges:
        u, v = e[0], e[1]
        w = Fraction(e[2]) if len(e) == 3 else Fraction(1)
        a[u][v] += w
        a[v][u] += w
    d = [sum(row) for row in a]
    p = [[a[i][j] / d[i] for j in range(n)] for i in range(n)]
    pt = [[Fraction(int(i == j)) for j in range(n)] for i in range(n)]
    for _ in range(t):
        pt = [[sum(pt[i][k] * p[k][j] for k in range(n)) for j in range(n)] for i in range(n)]

    def r2(row1, row2):
        return sum((row1[k] - row2[k]) ** 2 / d[k] for k in range(n))

    def mean_row(c):
        return [sum(pt[i][k] for i in c) / len(c) for k in range(n)]

    def sigma(comms):
        total = Fraction(0)
        for c in comms.values():
            m = mean_row(c)
            total += sum(r2(pt[i], m) for i in c)
        return total / n

    comms = {i: [i] for i in range(n)}
    seq = [sorted(sorted(c) for c in comms.values())]
    merges = []
    while True:
        cand = []
        for x, y in itertools.combinations(sorted(comms), 2):
            if any(a[i][j] and i != j for i in comms[x] for j in comms[y]):
                trial = {k: v for k, v in comms.items() if k not in (x, y)}
                trial[-1] = comms[x] + comms[y]
                cand.append((sigma(trial) - sigma(comms), x, y))
        if not cand:
            break
        ds, x, y = min(cand)
        merges.append((x, y, ds))
        comms[n + len(merges) - 1] = comms.pop(x) + comms.pop(y)
        seq.append(sorted(sorted(c) for c in comms.values()))
    wedges = [(e[0], e[1], float(e[2]) if len(e) == 3 else 1.0) for e in edges]
    best = max(range(len(seq)), key=lambda k: (round(modularity(n, wedges, seq[k]), 9), -k))
    return seq[best], merges


def test_worked_example() -> None:
    edges = [(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)]
    parts, merges = walktrap(6, edges, 2)
    assert parts == [[0, 1, 2], [3, 4, 5]]
    assert [(x, y) for x, y, _ in merges] == [(0, 1), (4, 5), (2, 6), (3, 7), (8, 9)]
    _, exact = oracle(6, edges, 2)
    for (_, _, got), (_, _, want) in zip(merges, exact):
        assert abs(got - float(want)) < 1e-12
    assert abs(modularity(6, [(u, v, 1.0) for u, v in edges], parts) - 5 / 14) < 1e-12


def test_edge_cases() -> None:
    assert walktrap(0, []) == ([], [])
    assert walktrap(1, []) == ([[0]], [])
    assert walktrap(3, []) == ([[0], [1], [2]], [])
    parts, merges = walktrap(4, [(0, 1), (2, 3)], 3)
    assert parts == [[0, 1], [2, 3]] and len(merges) == 2
    for bad in ([(0, 0)], [(0, 9)], [(0, 1, -1.0)]):
        try:
            walktrap(2, bad)
        except ValueError:
            pass
        else:
            raise AssertionError("expected ValueError")
    try:
        walktrap(2, [(0, 1)], 0)
    except ValueError:
        pass
    else:
        raise AssertionError("expected ValueError")


def test_random_against_definition() -> None:
    rng = random.Random(2005)
    for _ in range(500):
        n = rng.randint(1, 6)
        prob = rng.random()
        weighted = rng.random() < 0.3
        edges = []
        for u in range(n):
            for v in range(u + 1, n):
                if rng.random() < prob:
                    edges.append((u, v, rng.randint(1, 3)) if weighted else (u, v))
        t = rng.randint(1, 3)
        parts, merges = walktrap(n, edges, t)
        want_parts, want_merges = oracle(n, edges, t)
        assert [(x, y) for x, y, _ in merges] == [(x, y) for x, y, _ in want_merges]
        for (_, _, got), (_, _, want) in zip(merges, want_merges):
            assert abs(got - float(want)) < 1e-9
        assert parts == want_parts

What the next level needs

  • the tests did not run in this build

Read the full page about Walktrap algorithm