SS-tree in Python
Tree of bounding spheres for high-dimensional similarity search.
- File
ss_tree.py, 248 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/ss-tree/. The authors are listed in prov/G93DHE.jsonl.
"""The SS-tree of White and Jain: a balanced tree of bounding spheres.
Points live in leaves of at most M entries; every node other than the
root has at least m. Each node keeps the centroid of all the points below
it, the number of those points, and a radius such that the sphere around
the centroid holds every point below. Insert goes to the child with the
nearest centroid. A node that overflows is split on the coordinate of
highest variance of its entries' centroids, at the cut that makes the sum
of the variances of the two sides smallest. (White and Jain also use the
forced reinsert of the R*-tree; this code does not.) Queries: points in a
ball, and the k nearest neighbors by a best-first search.
"""
from __future__ import annotations
import heapq
import math
from typing import Hashable, List, Optional, Sequence, Tuple
Point = Tuple[float, ...]
def dist(a: Sequence[float], b: Sequence[float]) -> float:
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
class Node:
__slots__ = ("leaf", "entries", "center", "radius", "count", "parent")
def __init__(self, leaf: bool) -> None:
self.leaf = leaf
self.entries: list = [] # leaf: (point, id); internal: Node
self.center: Point = ()
self.radius = 0.0
self.count = 0
self.parent: Optional[Node] = None
def centers(self) -> List[Point]:
return [p for p, _ in self.entries] if self.leaf else [c.center for c in self.entries]
def refresh(self) -> None:
"""Recompute centroid, count and radius from the entries."""
if self.leaf:
pts = [p for p, _ in self.entries]
self.count = len(pts)
d = len(pts[0])
self.center = tuple(sum(p[t] for p in pts) / len(pts) for t in range(d)) # @line L5
self.radius = max(dist(self.center, p) for p in pts)
else:
self.count = sum(c.count for c in self.entries)
d = len(self.entries[0].center)
self.center = tuple(sum(c.center[t] * c.count for c in self.entries) / self.count
for t in range(d))
self.radius = max(dist(self.center, c.center) + c.radius for c in self.entries) # @line L6
def variance(vals: Sequence[float]) -> float:
mu = sum(vals) / len(vals)
return sum((v - mu) ** 2 for v in vals) / len(vals)
def split_entries(entries: list, centers: List[Point], m: int) -> Tuple[list, list]:
"""Split on the coordinate of highest variance, at the cut that minimizes
the sum of the variances of the two sides on that coordinate."""
d = len(centers[0])
axis = max(range(d), key=lambda t: (variance([c[t] for c in centers]), -t)) # @line L7
order = sorted(range(len(entries)), key=lambda i: (centers[i][axis], i))
vals = [centers[i][axis] for i in order]
n = len(entries)
best_k = min(range(m, n - m + 1),
key=lambda k: (variance(vals[:k]) + variance(vals[k:]), k)) # @line L8
return [entries[i] for i in order[:best_k]], [entries[i] for i in order[best_k:]]
class SSTree:
def __init__(self, max_entries: int = 4, min_entries: Optional[int] = None) -> None:
if max_entries < 2:
raise ValueError("M must be at least 2")
self.M = max_entries
self.m = min_entries if min_entries is not None else max(1, (2 * max_entries) // 5)
if not 1 <= self.m <= self.M // 2:
raise ValueError("need 1 <= m <= M/2")
self.root: Optional[Node] = None
self.n = 0
# ---- insertion ----
def insert(self, p: Sequence[float], oid: Hashable) -> None:
p = tuple(float(x) for x in p)
if self.root is None:
self.root = Node(True)
self._insert_entry((p, oid), 0)
self.n += 1
def _height(self) -> int:
h, u = 0, self.root
while not u.leaf:
u = u.entries[0]
h += 1
return h
def _insert_entry(self, e, level: int) -> None:
"""Insert a point (level 0) or a subtree whose leaves are at height level - 1."""
u = self.root
target = e[0] if level == 0 else e.center
for _ in range(self._height() - level): # @line L1
u = min(u.entries, key=lambda c: dist(c.center, target)) # @line L2
u.entries.append(e) # @line L3
if level > 0:
e.parent = u
while u is not None: # @line L4
if len(u.entries) > self.M:
self._split(u)
u.refresh()
u = u.parent
def _split(self, u: Node) -> None:
a, b = split_entries(u.entries, u.centers(), self.m)
u.entries = a
v = Node(u.leaf)
v.entries = b
if not u.leaf:
for c in b:
c.parent = v
v.refresh()
if u.parent is None: # @line L9
root = Node(False)
root.entries = [u, v]
u.parent = v.parent = root
self.root = root
u.refresh()
root.refresh()
else:
v.parent = u.parent
u.parent.entries.append(v) # @line L10
# ---- deletion ----
def delete(self, p: Sequence[float], oid: Hashable) -> bool:
p = tuple(float(x) for x in p)
leaf = self._find(self.root, p, oid) if self.root else None
if leaf is None:
return False
leaf.entries.remove((p, oid)) # @line L11
self.n -= 1
orphans: List[Tuple[object, int]] = []
u, h = leaf, 0
while u.parent is not None: # @line L12
par = u.parent
if len(u.entries) < self.m:
par.entries.remove(u)
orphans += [(e, h) for e in u.entries]
else:
u.refresh()
u, h = par, h + 1
if not self.root.entries: # the root was a leaf with one point
self.root = None
return True
self.root.refresh()
for e, h in orphans: # @line L13
self._insert_entry(e, h)
while not self.root.leaf and len(self.root.entries) == 1: # @line L14
self.root = self.root.entries[0]
self.root.parent = None
return True
def _find(self, u: Node, p: Point, oid) -> Optional[Node]:
if dist(u.center, p) > u.radius + 1e-9:
return None
if u.leaf:
return u if (p, oid) in u.entries else None
for c in u.entries:
r = self._find(c, p, oid)
if r is not None:
return r
return None
# ---- queries ----
def ball(self, q: Sequence[float], r: float) -> List[Hashable]:
"""Ids of the points within distance r of q."""
out: List[Hashable] = []
if self.root is None:
return out
stack = [self.root]
while stack:
u = stack.pop()
if dist(q, u.center) - u.radius > r: # @line L15
continue
if u.leaf:
out += [oid for p, oid in u.entries if dist(q, p) <= r]
else:
stack.extend(u.entries)
return out
def knn(self, q: Sequence[float], k: int) -> List[Tuple[float, Hashable]]:
"""The k nearest points as (distance, id), nearest first, by best-first search."""
out: List[Tuple[float, Hashable]] = []
if self.root is None or k <= 0:
return out
heap = [(max(0.0, dist(q, self.root.center) - self.root.radius), 0, 1, self.root)]
tick = 1
while heap and len(out) < k: # @line L16
d, kind, _, x = heapq.heappop(heap)
if kind == 1:
out.append((d, x)) # @line L17
continue
for e in x.entries:
tick += 1
if x.leaf:
heapq.heappush(heap, (dist(q, e[0]), 1, tick, e[1]))
else: # mindist of a sphere is a lower bound on the distance of its points
heapq.heappush(heap, (max(0.0, dist(q, e.center) - e.radius), 0, tick, e)) # @line L18
return out
# ---- checks ----
def height(self) -> int:
return -1 if self.root is None else self._height()
def check_invariant(self) -> None:
if self.root is None:
assert self.n == 0
return
depths = set()
total = 0
def walk(u: Node, d: int) -> List[Point]:
nonlocal total
assert len(u.entries) <= self.M
if u is not self.root:
assert len(u.entries) >= self.m
if u.leaf:
depths.add(d)
pts = [p for p, _ in u.entries]
total += len(pts)
else:
assert u is self.root and len(u.entries) >= 2 or u is not self.root
pts = []
for c in u.entries:
assert c.parent is u
pts += walk(c, d + 1)
assert u.count == len(pts)
cen = tuple(sum(p[t] for p in pts) / len(pts) for t in range(len(pts[0])))
assert dist(cen, u.center) < 1e-6
assert all(dist(u.center, p) <= u.radius + 1e-6 for p in pts)
return pts
walk(self.root, 0)
assert len(depths) == 1 and total == self.n
test_ss_tree.py 80 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ss-tree/. The authors are listed in prov/G93DHE.jsonl.
import math
import random
import pytest
from ss_tree import SSTree, dist, split_entries
P = {"a": (1, 1), "b": (2, 3), "c": (3, 1), "d": (8, 8), "e": (9, 6), "f": (7, 7),
"g": (2, 8), "h": (4, 4)}
def leaves(t):
nodes = [t.root] if t.root.leaf else t.root.entries
return sorted("".join(sorted(k for _, k in u.entries)) for u in nodes)
def test_page_example():
t = SSTree(4, 2)
for k in "abcde":
t.insert(P[k], k)
assert leaves(t) == ["abc", "de"]
assert t.root.center == pytest.approx((4.6, 3.8))
for k in "fgh":
t.insert(P[k], k)
assert leaves(t) == ["abch", "defg"]
t.check_invariant()
near = t.knn((5, 5), 3)
assert [k for _, k in near] == ["h", "f", "b"]
assert near[0][0] == pytest.approx(math.sqrt(2))
assert sorted(t.ball((3, 3), 2)) == ["b", "c", "h"]
for k in "def":
assert t.delete(P[k], k)
t.check_invariant()
assert leaves(t) == ["ac", "bgh"]
assert not t.delete((0, 0), "zz")
def test_small_and_errors():
t = SSTree(3, 1)
assert t.knn((0, 0), 2) == [] and t.ball((0, 0), 5) == []
t.insert((1, 2), "x")
assert t.knn((0, 0), 5) == [(math.sqrt(5), "x")]
assert t.delete((1, 2), "x") and t.root is None and t.height() == -1
with pytest.raises(ValueError):
SSTree(1)
with pytest.raises(ValueError):
SSTree(4, 3)
def test_split_uses_highest_variance_axis():
pts = [(0, 0), (0.1, 5), (0.2, 10), (0.3, 15), (0.4, 20)]
a, b = split_entries(list(range(5)), pts, 2)
assert (a, b) == ([0, 1], [2, 3, 4]) # split on y, not on x
def test_random_against_brute_force():
rng = random.Random(9)
for trial in range(500):
d = rng.choice([1, 2, 3, 5])
M = rng.randint(2, 7)
t = SSTree(M, rng.randint(1, M // 2))
pts = {}
for k in range(rng.randint(0, 50)):
p = tuple(float(rng.randint(0, 20)) for _ in range(d))
pts[k] = p
t.insert(p, k)
for k in rng.sample(sorted(pts), len(pts) // 3):
assert t.delete(pts.pop(k), k)
t.check_invariant()
assert t.n == len(pts)
for _ in range(3):
q = tuple(rng.uniform(-2, 22) for _ in range(d))
r = rng.uniform(0, 8)
assert sorted(t.ball(q, r)) == sorted(k for k, p in pts.items() if dist(q, p) <= r)
kk = rng.randint(1, 6)
got = [x for x, _ in t.knn(q, kk)]
want = sorted(dist(q, p) for p in pts.values())[:kk]
assert got == pytest.approx(want)
What the next level needs
- the tests did not run in this build