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