Geometric near-neighbor access tree in Python
A metric index that splits each node by several well-spread split points and stores distance ranges.
- File
gnat.py, 128 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/gnat/. The authors are listed in prov/2746FY.jsonl.
"""Geometric near-neighbor access tree (Brin): range and nearest neighbor search."""
import math
from typing import Callable, Optional, Sequence
Point = Sequence[float]
def euclidean(a: Point, b: Point) -> float:
return math.dist(a, b)
class Node:
__slots__ = ("splits", "ranges", "kids", "bucket")
def __init__(self) -> None:
self.splits: list[int] = []
self.ranges: list[list[tuple[float, float]]] = []
self.kids: list[Optional["Node"]] = []
self.bucket: list[int] = []
class GNAT:
def __init__(self, points: Sequence[Point], m: int = 3, leaf_size: int = 3,
dist: Callable[[Point, Point], float] = euclidean) -> None:
if m < 2:
raise ValueError("m must be at least 2")
self.points = list(points)
self.m, self.leaf_size, self.dist = m, max(leaf_size, 1), dist
self.evals = 0
self.root = self._build(list(range(len(self.points))))
def _d(self, a: Point, b: Point) -> float:
self.evals += 1
return self.dist(a, b)
def _build(self, ids: list[int]) -> Optional[Node]:
if not ids:
return None
node = Node()
if len(ids) <= self.leaf_size: # @line L1
node.bucket = ids
return node
pts = self.points
splits = [ids[0]] # @line L2
near = {x: self._d(pts[x], pts[ids[0]]) for x in ids}
while len(splits) < min(self.m, len(ids)):
far = max((x for x in ids if x not in splits), key=lambda x: (near[x], -x))
splits.append(far)
for x in ids:
near[x] = min(near[x], self._d(pts[x], pts[far]))
rest = [x for x in ids if x not in splits]
dto = {x: [self._d(pts[x], pts[s]) for s in splits] for x in rest}
groups: list[list[int]] = [[] for _ in splits]
for x in rest: # @line L3
groups[min(range(len(splits)), key=lambda j: (dto[x][j], j))].append(x)
k = len(splits)
ranges = [[(0.0, 0.0)] * k for _ in range(k)]
for i in range(k): # @line L4
for j in range(k):
ds = [self._d(pts[splits[i]], pts[splits[j]])] + [dto[x][i] for x in groups[j]]
ranges[i][j] = (min(ds), max(ds))
node.splits, node.ranges = splits, ranges
node.kids = [self._build(g) for g in groups] # @line L5
return node
def range(self, q: Point, r: float) -> list[int]:
out: list[int] = []
self._range(self.root, q, r, out)
return sorted(out)
def _range(self, node: Optional[Node], q: Point, r: float, out: list[int]) -> None:
if node is None:
return
if node.bucket: # @line L6
out.extend(x for x in node.bucket if self._d(q, self.points[x]) <= r)
return
alive = [True] * len(node.splits) # @line L7
for i, s in enumerate(node.splits): # @line L8
if not alive[i]:
continue
d = self._d(q, self.points[s])
if d <= r:
out.append(s)
for j in range(len(node.splits)): # @line L9
lo, hi = node.ranges[i][j]
if alive[j] and (d + r < lo or d - r > hi):
alive[j] = False
for j, kid in enumerate(node.kids): # @line L10
if alive[j]:
self._range(kid, q, r, out)
def nearest(self, q: Point) -> Optional[tuple[float, int]]:
best = [math.inf, -1]
self._nearest(self.root, q, best)
return None if best[1] < 0 else (best[0], best[1])
def _offer(self, best: list, d: float, x: int) -> None:
if (d, x) < (best[0], best[1]) or best[1] < 0:
best[0], best[1] = d, x
def _nearest(self, node: Optional[Node], q: Point, best: list) -> None:
if node is None:
return
if node.bucket:
for x in node.bucket:
self._offer(best, self._d(q, self.points[x]), x)
return
k = len(node.splits)
alive = [True] * k
dist = [math.inf] * k
for i, s in enumerate(node.splits): # @line L11
if not alive[i]:
continue
dist[i] = self._d(q, self.points[s])
self._offer(best, dist[i], s)
for j in range(k):
lo, hi = node.ranges[i][j]
if alive[j] and (dist[i] + best[0] < lo or dist[i] - best[0] > hi):
alive[j] = False
order = sorted((j for j in range(k) if alive[j]), key=lambda j: dist[j])
for j in order: # @line L12
lo_bound = max(max(dist[i] - node.ranges[i][j][1], node.ranges[i][j][0] - dist[i])
for i in range(k) if dist[i] < math.inf)
if lo_bound <= best[0]:
self._nearest(node.kids[j], q, best)
test_gnat.py 63 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/gnat/. The authors are listed in prov/2746FY.jsonl.
import math
import random
from gnat import GNAT
PTS = [(0, 0), (1, 1), (9, 0), (8, 1), (0, 9), (1, 8), (5, 5), (9, 9), (2, 3), (7, 6)]
def collect(node, out):
if node is None:
return
out.extend(node.bucket)
out.extend(node.splits)
for k in node.kids:
collect(k, out)
def test_example() -> None:
g = GNAT(PTS, m=3, leaf_size=2)
assert g.root.splits == [0, 7, 2]
assert [round(v, 2) for v in g.root.ranges[1][0]] == [8.06, 12.73]
assert [round(v, 2) for v in g.root.ranges[1][1]] == [0.0, 5.66]
g.evals = 0
assert g.range((6, 4), 2) == [6]
assert g.evals == 4
d, i = g.nearest((6, 4))
assert i == 6 and abs(d - math.sqrt(2)) < 1e-12
def test_edge_cases() -> None:
g = GNAT([])
assert g.range((0, 0), 5) == [] and g.nearest((0, 0)) is None
g = GNAT([(1, 1)] * 5, m=2, leaf_size=1)
assert g.range((1, 1), 0) == [0, 1, 2, 3, 4]
assert g.nearest((2, 1)) == (1.0, 0)
def test_random_against_scan() -> None:
rng = random.Random(2746)
for _ in range(500):
dim = rng.randint(1, 3)
n = rng.randint(0, 30)
grid = rng.random() < 0.4
pts = [tuple(float(rng.randint(0, 4)) if grid else rng.uniform(0, 10) for _ in range(dim))
for _ in range(n)]
g = GNAT(pts, m=rng.randint(2, 5), leaf_size=rng.randint(1, 4))
ids = []
collect(g.root, ids)
assert sorted(ids) == list(range(n))
for _ in range(3):
q = tuple(rng.uniform(-1, 11) for _ in range(dim))
r = rng.choice([0.0, 0.5, 1.0, 2.5, 6.0])
want = [i for i, p in enumerate(pts) if math.dist(q, p) <= r]
assert g.range(q, r) == want
res = g.nearest(q)
if n == 0:
assert res is None
else:
want_nn = min((math.dist(q, p), i) for i, p in enumerate(pts))
assert res == want_nn
What the next level needs
- the tests did not run in this build
Read the full page about Geometric near-neighbor access tree