Algorithm Search⌘K

Inverted file index in Python

Partitions vectors into k-means cells and searches only the cells nearest the query.

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

import heapq
import math
import random
from collections.abc import Sequence

Vector = tuple[float, ...]


def sq_dist(x: Sequence[float], y: Sequence[float]) -> float:
    return sum((a - b) * (a - b) for a, b in zip(x, y))


def lloyd(points: Sequence[Vector], m: int, iters: int, rng: random.Random) -> list[Vector]:
    """Lloyd's k-means: m centroids from m distinct random points, then iters rounds. O(iters n m d)."""
    distinct = list(dict.fromkeys(points))
    cent = rng.sample(distinct, min(m, len(distinct)))
    for _ in range(iters):
        groups: list[list[Vector]] = [[] for _ in cent]
        for x in points:
            groups[nearest_centroid(cent, x)].append(x)
        cent = [
            tuple(sum(c) / len(g) for c in zip(*g)) if g else cent[j] for j, g in enumerate(groups)
        ]
    return cent


def nearest_centroid(cent: Sequence[Vector], x: Sequence[float]) -> int:
    """Index of the nearest centroid, ties to the smallest index."""
    return min(range(len(cent)), key=lambda j: (sq_dist(x, cent[j]), j))


class IVFIndex:
    """Inverted file index: a list of point ids per k-means cell.

    Build O(t n m d) for t Lloyd rounds. Search O(m d + s d + s log k), s = points in the probed lists.
    """

    def __init__(
        self,
        points: Sequence[Vector],
        m: int,
        iters: int = 10,
        rng: random.Random | None = None,
        centroids: Sequence[Vector] | None = None,
    ) -> None:
        rng = rng if rng is not None else random.Random()
        self.points: list[Vector] = list(points)
        if centroids is not None:
            self.cent = list(centroids)
        else:
            self.cent = lloyd(self.points, m, iters, rng) if self.points else []  # @line L1
        self.lists: list[list[int]] = [[] for _ in self.cent]
        for i, x in enumerate(self.points):
            self.lists[nearest_centroid(self.cent, x)].append(i)  # @line L2

    def insert(self, x: Vector) -> int:
        """Add x to the list of its nearest centroid; return its id."""
        self.points.append(x)
        self.lists[nearest_centroid(self.cent, x)].append(len(self.points) - 1)
        return len(self.points) - 1

    def search(self, q: Vector, k: int, nprobe: int) -> list[tuple[float, int]]:
        """Return up to k pairs (squared distance, id), nearest first, from the nprobe nearest cells."""
        order = sorted(range(len(self.cent)), key=lambda j: (sq_dist(q, self.cent[j]), j))
        probe = order[:nprobe]  # @line L3
        heap: list[tuple[float, int]] = []  # @line L4
        for j in probe:  # @line L5
            for i in self.lists[j]:
                item = (-sq_dist(q, self.points[i]), -i)
                if len(heap) < k:
                    heapq.heappush(heap, item)
                elif item > heap[0]:
                    heapq.heapreplace(heap, item)
        return sorted((-a, -b) for a, b in heap)  # @line L6

    def search_exact(self, q: Vector, k: int) -> list[tuple[float, int]]:
        """Exact k nearest: probe cells by centroid distance, stop when the bisector bound fails."""
        if not self.cent:
            return []
        dq = [sq_dist(q, c) for c in self.cent]
        a = min(range(len(self.cent)), key=lambda j: (dq[j], j))

        def bound(j: int) -> float:
            if j == a:
                return 0.0
            gap = math.sqrt(sq_dist(self.cent[a], self.cent[j]))
            return max(0.0, (dq[j] - dq[a]) / (2 * gap)) if gap > 0 else 0.0

        cells = sorted(range(len(self.cent)), key=lambda j: (bound(j), j))
        heap: list[tuple[float, int]] = []
        for j in cells:
            if len(heap) == k and bound(j) ** 2 > -heap[0][0]:
                break
            for i in self.lists[j]:
                item = (-sq_dist(q, self.points[i]), -i)
                if len(heap) < k:
                    heapq.heappush(heap, item)
                elif item > heap[0]:
                    heapq.heapreplace(heap, item)
        return sorted((-a2, -b) for a2, b in heap)
test_inverted_file_index.py 62 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/inverted-file-index/. The authors are listed in prov/B1PB10.jsonl.

import random

from inverted_file_index import IVFIndex, lloyd, nearest_centroid, sq_dist

PTS = [(0, 1), (2, 2), (1, 0), (3, 2), (5, 5), (7, 4), (6, 6)]


def brute(points, q, k):
    return sorted((sq_dist(q, p), i) for i, p in enumerate(points))[:k]


def test_example() -> None:
    idx = IVFIndex(PTS, 2, centroids=[(1, 1), (6, 5)])
    assert idx.lists == [[0, 1, 2, 3], [4, 5, 6]]
    assert idx.search((4, 3), 1, 1) == [(5, 4)]
    assert idx.search((4, 3), 1, 2) == [(2, 3)]
    assert idx.search_exact((4, 3), 1) == [(2, 3)]


def test_lloyd_assigns_to_nearest() -> None:
    rng = random.Random(1)
    pts = [(rng.random(), rng.random()) for _ in range(100)]
    idx = IVFIndex(pts, 5, rng=rng)
    for j, lst in enumerate(idx.lists):
        for i in lst:
            assert nearest_centroid(idx.cent, pts[i]) == j
    assert sorted(i for lst in idx.lists for i in lst) == list(range(100))


def test_random_against_brute_force() -> None:
    rng = random.Random(99)
    for _ in range(500):
        d = rng.randint(1, 4)
        n = rng.randint(1, 40)
        pts = [tuple(float(rng.randint(-5, 5)) for _ in range(d)) for _ in range(n)]
        m = rng.randint(1, 6)
        idx = IVFIndex(pts, m, iters=rng.randint(0, 4), rng=rng)
        q = tuple(rng.uniform(-6, 6) for _ in range(d))
        k = rng.randint(1, 5)
        want = brute(pts, q, k)
        assert idx.search(q, k, len(idx.cent)) == want
        assert [a for a, _ in idx.search_exact(q, k)] == [a for a, _ in want]
        prev = None
        for nprobe in range(1, len(idx.cent) + 1):
            got = idx.search(q, k, nprobe)
            assert all(sq_dist(q, pts[i]) == a for a, i in got)
            if prev is not None and len(prev) == len(got) == k:
                assert got[-1][0] <= prev[-1][0]
            prev = got


def test_insert() -> None:
    rng = random.Random(3)
    pts = [(rng.random(), rng.random()) for _ in range(50)]
    idx = IVFIndex(pts[:30], 4, rng=rng)
    for p in pts[30:]:
        idx.insert(p)
    q = (0.5, 0.5)
    assert idx.search(q, 3, 4) == brute(pts, q, 3)

What the next level needs

  • the tests did not run in this build

Read the full page about Inverted file index