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
# 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