Algorithm Search⌘K

NN-Descent in Python

Approximately builds a kNN graph by repeatedly refining neighbors of neighbors.

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

import math
import random
from typing import Callable, Sequence


def _update(lst: list[list], u: int, dist: float) -> int:
    """Insert (dist, u, new) into the k-list lst if u is absent and dist beats the worst; return 1 if inserted."""
    if any(e[1] == u for e in lst):
        return 0
    worst = max(range(len(lst)), key=lambda j: (lst[j][0], lst[j][1]))
    if (dist, u) >= (lst[worst][0], lst[worst][1]):
        return 0
    lst[worst] = [dist, u, True]
    return 1


def nn_descent(points: Sequence, k: int, d: Callable = math.dist, rho: float | None = None,
               delta: float = 0.0, seed: int = 0, init=None, stats: dict | None = None) -> list[list[tuple[float, int]]]:
    """Approximate k-nearest-neighbor lists by NN-Descent (Dong, Charikar and Li), with local join.

    Returns, for each point, its list of (distance, index) pairs sorted by distance. rho is the sampling
    rate (None means no sampling); the loop stops when fewer than delta * n * k updates happen in a round.
    init, if given, is the list of initial neighbor indices of each point.
    """
    n = len(points)
    if not 0 <= k < n and n > 0:
        raise ValueError("need 0 <= k < n")
    rng = random.Random(seed)
    evals = 0

    def dist(i: int, j: int) -> float:
        nonlocal evals
        evals += 1
        return d(points[i], points[j])

    lists: list[list[list]] = []
    for v in range(n):  # @line L1
        nbrs = init[v] if init is not None else rng.sample([u for u in range(n) if u != v], k)
        lists.append([[dist(v, u), u, True] for u in nbrs])
    size = None if rho is None else max(1, math.ceil(rho * k))
    rounds = 0
    while True:  # @line L2
        rounds += 1
        old = [[e[1] for e in lists[v] if not e[2]] for v in range(n)]  # @line L3
        new = []
        for v in range(n):
            flagged = [e for e in lists[v] if e[2]]
            if size is not None and len(flagged) > size:
                flagged = rng.sample(flagged, size)
            for e in flagged:
                e[2] = False  # @line L4
            new.append([e[1] for e in flagged])
        rold = [[] for _ in range(n)]  # @line L5
        rnew = [[] for _ in range(n)]
        for v in range(n):
            for u in old[v]:
                rold[u].append(v)
            for u in new[v]:
                rnew[u].append(v)
        c = 0
        for v in range(n):  # @line L6
            ro, rn = rold[v], rnew[v]
            if size is not None:
                ro = rng.sample(ro, min(size, len(ro)))
                rn = rng.sample(rn, min(size, len(rn)))
            o = list(dict.fromkeys(old[v] + ro))  # @line L7
            nw = list(dict.fromkeys(new[v] + rn))
            for a in range(len(nw)):  # @line L8
                u1 = nw[a]
                for u2 in nw[a + 1:] + [x for x in o if x not in nw]:
                    if u1 == u2:
                        continue
                    l = dist(u1, u2)  # @line L9
                    c += _update(lists[u1], u2, l) + _update(lists[u2], u1, l)  # @line L10
        if c <= delta * n * k:  # @line L11
            break
    if stats is not None:
        stats["evaluations"] = evals
        stats["rounds"] = rounds
    return [sorted((e[0], e[1]) for e in lists[v]) for v in range(n)]  # @line L12
test_nn_descent.py 82 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/nn-descent/. The authors are listed in prov/264YR6.jsonl.

import math
import random

from nn_descent import nn_descent

P = [(0, 0), (1, 0), (2, 1), (6, 0), (7, 1), (6, 2), (1, 5), (2, 6)]
INIT = [[3, 6], [4, 7], [5, 0], [0, 6], [1, 7], [2, 3], [3, 4], [5, 1]]


def exact(points, k):
    return [sorted((math.dist(points[v], points[u]), u) for u in range(len(points)) if u != v)[:k]
            for v in range(len(points))]


def test_example() -> None:
    stats: dict = {}
    assert nn_descent(P, 2, init=INIT, stats=stats) == exact(P, 2)
    assert stats == {"evaluations": 68, "rounds": 3}


def test_edges() -> None:
    assert nn_descent([], 0) == []
    assert nn_descent([(0, 0)], 0) == [[]]
    pts = [(0, 0), (3, 4), (1, 1)]
    assert nn_descent(pts, 2, seed=5) == exact(pts, 2)
    try:
        nn_descent(pts, 3)
        raise AssertionError("k = n accepted")
    except ValueError:
        pass


def check_lists(points, k, lists):
    for v, lst in enumerate(lists):
        assert len(lst) == k and len({u for _, u in lst}) == k and all(u != v for _, u in lst)
        assert all(dd == math.dist(points[v], points[u]) for dd, u in lst)
        assert lst == sorted(lst)


def test_random_fixpoint_without_sampling() -> None:
    rng = random.Random(2011)
    for _ in range(500):
        n = rng.randint(2, 30)
        k = rng.randint(1, min(5, n - 1))
        pts = [tuple(rng.uniform(0, 10) for _ in range(rng.choice((1, 2, 3)))) for _ in range(1)]
        dim = len(pts[0])
        pts = [tuple(rng.uniform(0, 10) for _ in range(dim)) for _ in range(n)]
        lists = nn_descent(pts, k, seed=rng.randrange(10**6))
        check_lists(pts, k, lists)
        fwd = [{u for _, u in lst} for lst in lists]
        nbar = [set(f) for f in fwd]
        for v in range(n):
            for u in fwd[v]:
                nbar[u].add(v)
        for v in range(n):
            worst = lists[v][-1]
            for u in nbar[v]:
                for w in nbar[u]:
                    if w != v and w not in fwd[v]:
                        assert (math.dist(pts[v], pts[w]), w) >= worst
        if k == n - 1:
            assert lists == exact(pts, k)


def test_random_with_sampling() -> None:
    rng = random.Random(577)
    for _ in range(500):
        n = rng.randint(2, 40)
        k = rng.randint(1, min(6, n - 1))
        pts = [tuple(rng.uniform(0, 10) for _ in range(2)) for _ in range(n)]
        init = [rng.sample([u for u in range(n) if u != v], k) for v in range(n)]
        lists = nn_descent(pts, k, rho=rng.choice((0.3, 0.5, 1.0)), delta=rng.choice((0.0, 0.001, 0.05)),
                           seed=rng.randrange(10**6), init=init)
        check_lists(pts, k, lists)
        truth = exact(pts, k)
        for v in range(n):
            start = sorted(math.dist(pts[v], pts[u]) for u in init[v])
            got = [dd for dd, _ in lists[v]]
            assert all(t[0] <= g <= s for t, g, s in zip(truth[v], got, start))

What the next level needs

  • the tests did not run in this build

Read the full page about NN-Descent