Algorithm Search⌘K

LSH forest in Python

Stores LSH hash prefixes in prefix trees so the number of hash functions need not be fixed in advance.

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

import bisect
import math
import random
from typing import Sequence

Vector = Sequence[float]


def dot(u: Vector, v: Vector) -> float:
    return sum(a * b for a, b in zip(u, v))


def angle(u: Vector, v: Vector) -> float:
    nu, nv = math.sqrt(dot(u, u)), math.sqrt(dot(v, v))
    if nu == 0 or nv == 0:
        return math.pi / 2
    return math.acos(max(-1.0, min(1.0, dot(u, v) / (nu * nv))))


class LSHForest:
    """LSH forest (Bawa, Condie and Ganesan) with random hyperplane bits, for the angular distance.

    Each of the num_trees trees stores the points sorted by a label of max_depth bits; a sorted array
    with binary search plays the role of the prefix tree.
    """

    def __init__(self, points: Sequence[Vector], max_depth: int, num_trees: int,
                 seed: int = 0, planes=None) -> None:
        self.points = [tuple(p) for p in points]
        self.K = max_depth
        if planes is None:
            rng = random.Random(seed)
            dim = len(self.points[0]) if self.points else 0
            planes = [[[rng.gauss(0.0, 1.0) for _ in range(dim)] for _ in range(max_depth)]  # @line L1
                      for _ in range(num_trees)]
        self.planes = planes
        self.trees: list[tuple[list[tuple[int, ...]], list[int]]] = []
        for r in self.planes:
            labelled = sorted((self.label(r, p), i) for i, p in enumerate(self.points))  # @line L2
            self.trees.append(([lab for lab, _ in labelled], [i for _, i in labelled]))  # @line L3

    @staticmethod
    def label(r, v: Vector) -> tuple[int, ...]:
        return tuple(1 if dot(rj, v) >= 0 else 0 for rj in r)

    @staticmethod
    def _range(labels: list[tuple[int, ...]], prefix: tuple[int, ...]) -> tuple[int, int]:
        lo = bisect.bisect_left(labels, prefix)
        hi = bisect.bisect_left(labels, prefix + (2,))
        return lo, hi

    def _depth(self, labels: list[tuple[int, ...]], lam: tuple[int, ...]) -> int:
        """Length of the longest prefix of lam shared by some stored label."""
        best = 0
        pos = bisect.bisect_left(labels, lam)
        for j in (pos - 1, pos):
            if 0 <= j < len(labels):
                lcp = 0
                while lcp < self.K and labels[j][lcp] == lam[lcp]:
                    lcp += 1
                best = max(best, lcp)
        return best

    def candidates(self, q: Vector, m: int) -> tuple[set[int], int]:
        """The candidate set and the depth at which the synchronous descent stopped."""
        n = len(self.points)
        if n == 0:
            return set(), 0
        lams = [self.label(r, q) for r in self.planes]
        x = max(self._depth(labels, lam) for (labels, _), lam in zip(self.trees, lams))  # @line L4
        while True:  # @line L5
            cand: set[int] = set()
            for (labels, ids), lam in zip(self.trees, lams):  # @line L6
                lo, hi = self._range(labels, lam[:x])
                cand.update(ids[lo:hi])
            if len(cand) >= min(m, n) or x == 0:  # @line L7
                return cand, x
            x -= 1  # @line L8

    def query(self, q: Vector, k: int, m: int) -> list[int]:
        """Indices of the k points of smallest angle to q among the candidates, at least min(m, n) of them."""
        cand, _ = self.candidates(q, m)
        return sorted(cand, key=lambda i: (angle(self.points[i], q), i))[:k]  # @line L9
test_lsh_forest.py 62 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lsh-forest/. The authors are listed in prov/TVZ64D.jsonl.

import random

from lsh_forest import LSHForest, angle

P = [(4, 1), (3, 2), (1, 3), (-2, 3), (-3, -1), (1, -3), (4, -1)]
PLANES = [[(1, 0), (0, 1), (1, 1)], [(1, -1), (0, 1), (-1, 2)]]


def test_example() -> None:
    forest = LSHForest(P, 3, 2, planes=PLANES)
    assert [forest.trees[0][1][j] for j in range(7)] == [4, 3, 5, 6, 0, 1, 2]
    q = (2, 1)
    assert forest.candidates(q, 2) == ({0, 1, 2}, 3)
    assert forest.candidates(q, 4) == ({0, 1, 2, 5, 6}, 1)
    assert forest.candidates(q, 6) == (set(range(7)), 0)
    assert forest.query(q, 1, 2) == [1]
    assert forest.query(q, 2, 4) == [1, 0]


def test_edges() -> None:
    empty = LSHForest([], 4, 2)
    assert empty.query((1.0, 0.0), 3, 5) == []
    one = LSHForest([(1.0, 1.0)], 4, 2, seed=3)
    assert one.query((-1.0, 0.0), 1, 1) == [0]
    zero_depth = LSHForest(P, 0, 1, seed=1)
    assert zero_depth.candidates((1, 0), 1) == (set(range(7)), 0)


def shared(a, b):
    n = 0
    while n < len(a) and a[n] == b[n]:
        n += 1
    return n


def test_random_against_brute_force() -> None:
    rng = random.Random(2005)
    for _ in range(600):
        n = rng.randint(1, 40)
        dim = rng.randint(2, 5)
        pts = [tuple(rng.gauss(0, 1) for _ in range(dim)) for _ in range(n)]
        depth, trees = rng.randint(1, 8), rng.randint(1, 4)
        forest = LSHForest(pts, depth, trees, seed=rng.randrange(10**6))
        q = tuple(rng.gauss(0, 1) for _ in range(dim))
        m, k = rng.randint(1, n + 3), rng.randint(1, 5)
        lams = [forest.label(r, q) for r in forest.planes]
        labs = [[forest.label(r, p) for p in pts] for r in forest.planes]

        def cset(x):
            return {i for i in range(n) if any(shared(labs[t][i], lams[t]) >= x for t in range(trees))}

        xs = [x for x in range(depth + 1) if len(cset(x)) >= min(m, n)]
        x_star = max(xs)
        cand, x = forest.candidates(q, m)
        assert x == x_star and cand == cset(x_star)
        best = sorted(cand, key=lambda i: (angle(pts[i], q), i))[:k]
        assert forest.query(q, k, m) == best
        if m >= n:
            assert forest.query(q, k, m) == sorted(range(n), key=lambda i: (angle(pts[i], q), i))[:k]

What the next level needs

  • the tests did not run in this build

Read the full page about LSH forest