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