Algorithm Search⌘K

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

Read the full page about Multi-probe LSH