Multi-probe LSH in Python
Probes several nearby buckets per hash table so fewer tables are needed for the same recall.
- File
multi_probe_lsh.py, 99 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/multi-probe-lsh/. The authors are listed in prov/9SW2DV.jsonl.
import heapq
import math
import random
from typing import Iterator, Sequence
Vector = Sequence[float]
def dot(u: Vector, v: Vector) -> float:
return sum(a * b for a, b in zip(u, v))
def perturbations(x_minus: Sequence[float], w: float) -> Iterator[tuple[tuple[int, ...], float]]:
"""Yield every perturbation vector in {-1, 0, 1}^M other than 0, with its score, in nondecreasing score.
x_minus[j] is the distance from f_j(q) to the left end of its slot, in [0, w).
The score of a vector delta is the sum over j with delta_j != 0 of x_j(delta_j)^2.
"""
m = len(x_minus)
pairs = []
for j, xm in enumerate(x_minus):
pairs.append((xm, j, -1))
pairs.append((w - xm, j, +1))
pairs.sort() # @line L9
z2 = [p[0] ** 2 for p in pairs]
if m == 0:
return
heap = [(z2[0], (0,))] # @line L10
while heap:
score, a = heapq.heappop(heap) # @line L11
top = a[-1]
if top + 1 < 2 * m: # @line L12
heapq.heappush(heap, (score - z2[top] + z2[top + 1], a[:-1] + (top + 1,))) # shift
heapq.heappush(heap, (score + z2[top + 1], a + (top + 1,))) # expand
coords = [pairs[i][1] for i in a]
if len(set(coords)) == len(coords): # @line L13
delta = [0] * m
for i in a:
delta[pairs[i][1]] = pairs[i][2]
yield tuple(delta), score
class MultiProbeLSH:
"""Euclidean LSH with p-stable hashes h(v) = floor((a.v + b) / w) and query-directed multi-probing."""
def __init__(self, points: Sequence[Vector], m: int, num_tables: int, w: float,
seed: int = 0, functions=None) -> None:
self.points = [tuple(p) for p in points]
self.m, self.w = m, w
if functions is None:
rng = random.Random(seed)
d = len(self.points[0]) if self.points else 0
functions = [ # @line L1
([[rng.gauss(0.0, 1.0) for _ in range(d)] for _ in range(m)], [rng.uniform(0.0, w) for _ in range(m)])
for _ in range(num_tables)
]
self.functions = functions
self.tables: list[dict[tuple[int, ...], list[int]]] = []
for a, b in self.functions:
table: dict[tuple[int, ...], list[int]] = {}
for idx, p in enumerate(self.points):
table.setdefault(self._key(a, b, p)[0], []).append(idx) # @line L2
self.tables.append(table)
def _key(self, a, b, v: Vector) -> tuple[tuple[int, ...], list[float]]:
f = [dot(aj, v) + bj for aj, bj in zip(a, b)]
h = tuple(math.floor(fj / self.w) for fj in f)
x_minus = [fj - self.w * hj for fj, hj in zip(f, h)]
return h, x_minus
def probed_keys(self, q: Vector, probes: int) -> list[list[tuple[int, ...]]]:
"""For each table, the home key of q followed by the keys of its first `probes` perturbations."""
out = []
for a, b in self.functions:
h, x_minus = self._key(a, b, q) # @line L5
keys = [h] # @line L6
for delta, _ in perturbations(x_minus, self.w): # @line L7
if len(keys) > probes:
break
keys.append(tuple(hj + dj for hj, dj in zip(h, delta)))
out.append(keys)
return out
def candidates(self, q: Vector, probes: int) -> set[int]:
cand: set[int] = set() # @line L3
for table, keys in zip(self.tables, self.probed_keys(q, probes)): # @line L4
for k in keys:
cand.update(table.get(k, ()))
return cand
def query(self, q: Vector, probes: int) -> int | None:
"""Index of the nearest point of q among the candidates, or None if there is none."""
cand = self.candidates(q, probes)
if not cand:
return None
return min(cand, key=lambda i: (math.dist(self.points[i], q), i)) # @line L8
test_multi_probe_lsh.py 86 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/multi-probe-lsh/. The authors are listed in prov/9SW2DV.jsonl.
import itertools
import math
import random
from multi_probe_lsh import MultiProbeLSH, perturbations
POINTS = [(1, 1), (4.3, 2.0), (2.5, 4.5), (6, 6), (-2, 5), (9, 1), (0, -3), (3, 0)]
GRID = [([[1, 0], [0, 1]], [0.5, 1.0])]
def score(delta, x_minus, w):
return sum((xm if dj == -1 else w - xm) ** 2 for dj, xm in zip(delta, x_minus) if dj)
def test_example_sequence() -> None:
seq = [d for d, _ in perturbations([3.7, 3.5], 4.0)]
assert seq == [(1, 0), (0, 1), (1, 1), (0, -1), (1, -1), (-1, 0), (-1, 1), (-1, -1)]
def test_example_query() -> None:
lsh = MultiProbeLSH(POINTS, 2, 1, 4.0, functions=GRID)
q = (3.2, 2.5)
assert sorted(lsh.candidates(q, 0)) == [0, 7]
assert lsh.query(q, 0) == 7
assert lsh.query(q, 1) == 1
assert sorted(lsh.candidates(q, 4)) == [0, 1, 2, 3, 6, 7]
def test_edges() -> None:
assert list(perturbations([], 1.0)) == []
assert [d for d, _ in perturbations([0.25], 1.0)] == [(-1,), (1,)]
lsh = MultiProbeLSH([], 3, 2, 1.0)
assert lsh.query((0.0, 0.0), 5) is None
one = MultiProbeLSH([(0.0, 0.0)], 2, 1, 1.0, seed=1)
assert one.query((0.0, 0.0), 0) == 0
def test_perturbations_against_enumeration() -> None:
rng = random.Random(2007)
for _ in range(600):
m = rng.randint(1, 5)
w = rng.uniform(0.5, 4.0)
xm = [rng.uniform(0.0, w) for _ in range(m)]
got = list(perturbations(xm, w))
scores = [s for _, s in got]
assert all(a <= b + 1e-12 for a, b in zip(scores, scores[1:]))
everything = {d for d in itertools.product((-1, 0, 1), repeat=m) if any(d)}
assert len(got) == len(everything) == len({d for d, _ in got})
assert {d for d, _ in got} == everything
for d, s in got:
assert math.isclose(s, score(d, xm, w), abs_tol=1e-9)
def test_query_against_brute_force() -> None:
rng = random.Random(950)
for _ in range(500):
n = rng.randint(1, 30)
dim = rng.randint(1, 4)
pts = [tuple(rng.uniform(-5, 5) for _ in range(dim)) for _ in range(n)]
m, tables, w = rng.randint(1, 4), rng.randint(1, 3), rng.uniform(0.5, 3.0)
lsh = MultiProbeLSH(pts, m, tables, w, seed=rng.randrange(10**6))
q = tuple(rng.uniform(-5, 5) for _ in range(dim))
probes = rng.randint(0, 3**m)
keys = lsh.probed_keys(q, probes)
expected = set()
for (a, b), ks in zip(lsh.functions, keys):
assert len(ks) == min(probes, 3**m - 1) + 1 and len(set(ks)) == len(ks)
for i, p in enumerate(pts):
if lsh._key(a, b, p)[0] in ks:
expected.add(i)
assert lsh.candidates(q, probes) == expected
best = lsh.query(q, probes)
if expected:
assert math.dist(pts[best], q) == min(math.dist(pts[i], q) for i in expected)
else:
assert best is None
assert lsh.candidates(q, 0) <= expected
if probes == 3**m - 1:
for (a, b) in lsh.functions:
hq = lsh._key(a, b, q)[0]
for i, p in enumerate(pts):
if max(abs(x - y) for x, y in zip(lsh._key(a, b, p)[0], hq)) <= 1:
assert i in expected
What the next level needs
- the tests did not run in this build