Random projection tree in Python
A space-partitioning tree that splits along random directions and adapts to low intrinsic dimension.
- File
rp_tree.py, 109 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/rp-tree/. The authors are listed in prov/58J4K4.jsonl.
import heapq
import math
import random
from typing import Sequence
class _Node:
__slots__ = ("v", "t", "left", "right", "items")
def __init__(self, v=None, t=0.0, left=None, right=None, items=None):
self.v, self.t, self.left, self.right, self.items = v, t, left, right, items
def _dot(a: Sequence[float], b: Sequence[float]) -> float:
return sum(x * y for x, y in zip(a, b))
class RPTree:
"""Random projection tree with median splits along random unit directions.
points: a sequence of equal-length vectors; leaf_size: the largest leaf n0; directions: optional list of
directions used in the preorder of node creation (for examples), else Gaussian directions from seed.
"""
def __init__(self, points: Sequence[Sequence[float]], leaf_size: int = 8, seed: int = 0, directions=None):
if leaf_size < 1:
raise ValueError("leaf_size must be at least 1")
self.points = [tuple(map(float, p)) for p in points]
self.leaf_size = leaf_size
self._rng = random.Random(seed)
self._dirs = list(directions) if directions is not None else None
self.evaluations = 0
self.root = self._build(list(range(len(self.points))))
def _direction(self) -> tuple[float, ...]:
dim = len(self.points[0])
if self._dirs:
v = tuple(map(float, self._dirs.pop(0)))
else:
v = tuple(self._rng.gauss(0.0, 1.0) for _ in range(dim))
norm = math.sqrt(_dot(v, v))
return tuple(x / norm for x in v)
def _build(self, idx: list[int]) -> _Node:
if len(idx) <= self.leaf_size: # @line L1
return _Node(items=idx)
v = self._direction() # @line L2
proj = sorted(_dot(v, self.points[i]) for i in idx)
t = proj[(len(idx) - 1) // 2] # @line L3
left = [i for i in idx if _dot(v, self.points[i]) <= t] # @line L4
right = [i for i in idx if _dot(v, self.points[i]) > t]
if not right: # @line L5
return _Node(items=idx)
return _Node(v, t, self._build(left), self._build(right)) # @line L6
def leaves(self) -> list[list[int]]:
out, stack = [], [self.root]
while stack:
node = stack.pop()
if node.items is not None:
out.append(node.items)
else:
stack += [node.right, node.left]
return out
def depth(self, node=None) -> int:
node = self.root if node is None else node
if node.items is not None:
return 0
return 1 + max(self.depth(node.left), self.depth(node.right))
def leaf_of(self, q: Sequence[float]) -> list[int]:
"""The leaf that a defeatist search for q reaches."""
node = self.root
while node.items is None:
node = node.left if _dot(node.v, q) - node.t <= 0 else node.right
return node.items
def query(self, q: Sequence[float], k: int = 1) -> list[tuple[float, int]]:
"""The k nearest points to q, exactly, as sorted (distance, index) pairs."""
self.evaluations = 0
heap: list[tuple[float, int]] = [] # max-heap of (-dist, -idx)
if k <= 0 or not self.points:
return []
def worst() -> tuple[float, int]:
return (-heap[0][0], -heap[0][1])
def visit(node: _Node) -> None:
if node.items is not None: # @line L7
for i in node.items:
self.evaluations += 1
item = (math.dist(q, self.points[i]), i)
if len(heap) < k:
heapq.heappush(heap, (-item[0], -item[1]))
elif item < worst():
heapq.heapreplace(heap, (-item[0], -item[1]))
return
g = _dot(node.v, q) - node.t # @line L8
near, far = (node.left, node.right) if g <= 0 else (node.right, node.left) # @line L9
visit(near) # @line L10
if len(heap) < k or abs(g) <= worst()[0] * (1 + 1e-12) + 1e-12: # @line L11
visit(far)
visit(self.root)
return sorted((-a, -b) for a, b in heap)
test_rp_tree.py 83 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/rp-tree/. The authors are listed in prov/58J4K4.jsonl.
import math
import random
from rp_tree import RPTree, _dot
P = [(0, 0), (1, 1), (2, 1), (3, 2), (4, 4), (5, 4), (6, 5), (7, 7)]
def brute(points, q, k):
return sorted((math.dist(q, p), i) for i, p in enumerate(points))[:k]
def check_structure(tree, n):
leaves = tree.leaves()
assert sorted(i for leaf in leaves for i in leaf) == list(range(n))
def walk(node, idx):
if node.items is not None:
assert sorted(node.items) == sorted(idx)
return
assert abs(_dot(node.v, node.v) - 1) < 1e-9
left = [i for i in idx if _dot(node.v, tree.points[i]) <= node.t]
right = [i for i in idx if _dot(node.v, tree.points[i]) > node.t]
assert left and right and len(left) == (len(idx) + 1) // 2
walk(node.left, left)
walk(node.right, right)
walk(tree.root, list(range(n)))
def test_example() -> None:
tree = RPTree(P, 2, directions=[(0.6, 0.8), (0.8, -0.6), (0, 1)])
assert tree.leaves() == [[0, 1], [2, 3], [4, 5], [6, 7]]
assert math.isclose(tree.root.t, 3.4) and math.isclose(tree.root.left.t, 0.2) and tree.root.right.t == 4
assert tree.query((4.5, 3), 1) == [(math.sqrt(1.25), 4)] and tree.evaluations == 4
assert tree.query((4.5, 3), 2) == [(math.sqrt(1.25), 4), (math.sqrt(1.25), 5)] and tree.evaluations == 4
assert tree.leaf_of((4.5, 3)) == [4, 5]
assert tree.depth() == 2
def test_edges() -> None:
assert RPTree([], 2).query((0, 0), 3) == []
one = RPTree([(1, 2)], 1)
assert one.query((0, 0), 5) == [(math.sqrt(5), 0)]
same = RPTree([(1, 1)] * 5, 1)
assert same.leaves() == [[0, 1, 2, 3, 4]]
assert same.query((1, 1), 2) == [(0.0, 0), (0.0, 1)]
assert RPTree(P, 3).query((0, 0), 0) == []
try:
RPTree(P, 0)
raise AssertionError("leaf_size 0 accepted")
except ValueError:
pass
def test_random_against_brute_force() -> None:
rng = random.Random(2008)
for _ in range(500):
n = rng.randint(1, 60)
dim = rng.randint(1, 5)
if rng.random() < 0.2:
pts = [tuple(float(rng.randint(0, 3)) for _ in range(dim)) for _ in range(n)]
else:
pts = [tuple(rng.uniform(-5, 5) for _ in range(dim)) for _ in range(n)]
n0 = rng.randint(1, 6)
tree = RPTree(pts, n0, seed=rng.randrange(10**6))
if len(set(pts)) == n:
check_structure(tree, n)
assert all(len(leaf) <= n0 for leaf in tree.leaves())
assert tree.depth() == (0 if n <= n0 else math.ceil(math.log2(n / n0) - 1e-12))
else:
assert sorted(i for leaf in tree.leaves() for i in leaf) == list(range(n))
for _ in range(3):
q = tuple(rng.uniform(-6, 6) for _ in range(dim))
k = rng.randint(1, n + 1)
got = tree.query(q, k)
want = brute(pts, q, k)
assert [round(x, 9) for x, _ in got] == [round(x, 9) for x, _ in want]
assert tree.evaluations <= n
assert tree.leaf_of(q) in tree.leaves()
What the next level needs
- the tests did not run in this build