Algorithm Search⌘K

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
rp_tree.pyRawGitHub
# 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

Read the full page about Random projection tree