Algorithm Search⌘K

M-tree in Python

Balanced metric-space tree that stores covering balls and supports dynamic insertion.

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

import heapq
import itertools
import math
from dataclasses import dataclass, field
from typing import Callable, Generic, Optional, TypeVar

T = TypeVar("T")
Metric = Callable[[T, T], float]


@dataclass
class Entry(Generic[T]):
    obj: T
    radius: float = 0.0  # covering radius; 0 in a leaf
    child: Optional["Node[T]"] = None  # None in a leaf
    dparent: Optional[float] = None  # distance to the routing object of the parent entry; None at the root


@dataclass
class Node(Generic[T]):
    leaf: bool
    entries: list[Entry[T]] = field(default_factory=list)


def _balanced(n: int, dm: list[list[float]], i: int, j: int) -> tuple[list[int], list[int]]:
    """Split range(n) between the promoted entries i and j, taking turns, each its nearest free entry."""
    free = set(range(n)) - {i, j}
    a, b = [i], [j]
    turn = 0
    while free:
        part, p = (a, i) if turn == 0 else (b, j)
        t = min(free, key=lambda t: (dm[t][p], t))
        part.append(t)
        free.remove(t)
        turn ^= 1
    return a, b


class MTree(Generic[T]):
    """An M-tree with node capacity M >= 2, insertion one object at a time, range and k-NN queries.

    A split promotes the pair of entries that minimizes the larger covering radius (mM_RAD),
    with the balanced partition: the two promoted objects take turns picking their nearest
    remaining entry. Every node but the root then holds at least floor((M + 1) / 2) entries.
    evaluations counts metric calls.
    """

    def __init__(self, d: Metric, capacity: int = 4) -> None:
        if capacity < 2:
            raise ValueError("capacity must be at least 2")
        self._d, self.M = d, capacity
        self.root: Node[T] = Node(leaf=True)
        self.evaluations = 0
        self.size = 0

    def dist(self, a: T, b: T) -> float:
        self.evaluations += 1
        return self._d(a, b)

    # ---------- queries ----------

    def range(self, q: T, r: float) -> list[T]:
        """All stored objects x with d(q, x) <= r."""
        out: list[T] = []
        self._range(self.root, q, r, None, out)
        return out

    def _range(self, node: Node[T], q: T, r: float, dqp: Optional[float], out: list[T]) -> None:
        for e in node.entries:  # @line L1
            if dqp is not None and abs(dqp - e.dparent) > r + e.radius:  # @line L2
                continue
            de = self.dist(q, e.obj)  # @line L3
            if node.leaf:  # @line L4
                if de <= r:  # @line L5
                    out.append(e.obj)
            elif de <= r + e.radius:  # @line L6
                self._range(e.child, q, r, de, out)  # @line L7

    def knn(self, q: T, k: int) -> list[tuple[float, T]]:
        """The k nearest stored objects with their distances, nearest first (ties in any order)."""
        if k <= 0:
            return []
        tick = itertools.count()
        best: list[tuple[float, int, T]] = []  # max-heap by negated distance
        tau = math.inf
        pq: list[tuple[float, int, Node[T], Optional[float]]] = [(0.0, next(tick), self.root, None)]
        while pq:  # @line L8
            dmin, _, node, dqp = heapq.heappop(pq)  # @line L9
            if dmin > tau:  # @line L10
                break
            for e in node.entries:  # @line L11
                if dqp is not None and abs(dqp - e.dparent) - e.radius > tau:  # @line L12
                    continue
                de = self.dist(q, e.obj)  # @line L13
                if node.leaf:
                    if de < tau or len(best) < k:  # @line L14
                        heapq.heappush(best, (-de, next(tick), e.obj))
                        if len(best) > k:
                            heapq.heappop(best)
                        if len(best) == k:
                            tau = -best[0][0]
                else:
                    lb = max(de - e.radius, 0.0)
                    if lb <= tau:  # @line L15
                        heapq.heappush(pq, (lb, next(tick), e.child, de))
        return sorted(((-nd, x) for nd, _, x in best), key=lambda t: t[0])  # @line L16

    # ---------- insertion ----------

    def insert(self, x: T) -> None:
        split = self._insert(self.root, x, None, None)
        if split is not None:  # @line L17
            self.root = Node(leaf=False, entries=list(split))
        self.size += 1

    def _insert(self, node: Node[T], x: T, pobj: Optional[T], dxp: Optional[float]):
        """Insert x below node; pobj is the routing object of node (None at the root), dxp = d(x, pobj)."""
        if node.leaf:  # @line L18
            node.entries.append(Entry(x, dparent=dxp))
        else:
            ds = [self.dist(x, e.obj) for e in node.entries]  # @line L19
            inside = [i for i, e in enumerate(node.entries) if ds[i] <= e.radius]
            if inside:  # @line L20
                i = min(inside, key=lambda i: ds[i])
            else:
                i = min(range(len(ds)), key=lambda i: ds[i] - node.entries[i].radius)
                node.entries[i].radius = ds[i]  # @line L21
            e = node.entries[i]
            split = self._insert(e.child, x, e.obj, ds[i])  # @line L22
            if split is not None:
                for s2 in split:
                    s2.dparent = None if pobj is None else self.dist(s2.obj, pobj)
                node.entries[i : i + 1] = list(split)
        if len(node.entries) > self.M:  # @line L23
            return self._split(node)
        return None

    def _split(self, node: Node[T]) -> tuple[Entry[T], Entry[T]]:
        es = node.entries
        n = len(es)
        dm = [[0.0] * n for _ in range(n)]
        for i in range(n):  # @line L24
            for j in range(i + 1, n):
                dm[i][j] = dm[j][i] = self.dist(es[i].obj, es[j].obj)
        best = None
        for i in range(n):  # @line L25
            for j in range(i + 1, n):
                a, b = _balanced(n, dm, i, j)
                ra = max(dm[t][i] + es[t].radius for t in a)
                rb = max(dm[t][j] + es[t].radius for t in b)
                if best is None or max(ra, rb) < best[0]:
                    best = (max(ra, rb), i, j, a, b, ra, rb)
        _, i, j, a, b, ra, rb = best
        out = []
        for p, part, rad in ((i, a, ra), (j, b, rb)):  # @line L26
            child = Node(node.leaf, [es[t] for t in part])
            for t in part:
                es[t].dparent = dm[t][p]
            out.append(Entry(es[p].obj, rad, child))
        return out[0], out[1]  # @line L27
test_m_tree.py 115 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/m-tree/. The authors are listed in prov/GE79TV.jsonl.

import math
import random

from m_tree import MTree

P = [(1, 1), (2, 6), (4, 4), (5, 1), (6, 7), (8, 3), (9, 8), (3, 9), (7, 5), (0, 4), (10, 2), (6, 2)]


def edit(a: str, b: str) -> int:
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def check_invariants(t: MTree) -> int:
    """Covering radii, parent distances and equal leaf depth; returns the number of objects."""
    depths = set()

    def objects(node, parent, depth):
        found = []
        assert len(node.entries) <= t.M
        if parent is not None:
            assert len(node.entries) >= (t.M + 1) // 2
        for e in node.entries:
            if parent is None:
                assert e.dparent is None
            else:
                assert abs(e.dparent - t._d(e.obj, parent)) < 1e-9
            if node.leaf:
                found.append(e.obj)
                depths.add(depth)
            else:
                sub = objects(e.child, e.obj, depth + 1)
                assert all(t._d(e.obj, y) <= e.radius + 1e-9 for y in sub)
                found += sub
        return found

    n = len(objects(t.root, None, 0))
    assert len(depths) <= 1
    if t.M >= 3 and depths and max(depths) >= 1:
        h = max(depths) + 1
        assert n >= 2 * ((t.M + 1) // 2) ** (h - 1)
    return n


def build(points, d, capacity):
    t = MTree(d, capacity)
    for x in points:
        t.insert(x)
    return t


def test_example() -> None:
    t = MTree(math.dist, 4)
    costs = []
    for x in P:
        t.evaluations = 0
        t.insert(x)
        costs.append(t.evaluations)
    assert costs == [0, 0, 0, 0, 10, 2, 2, 12, 3, 3, 3, 13]
    assert check_invariants(t) == 12
    roots = [(e.obj, round(e.radius, 4)) for e in t.root.entries]
    assert roots == [((2, 6), 3.1623), ((6, 7), 3.1623), ((5, 1), 4.0), ((8, 3), 2.2361)]
    t.evaluations = 0
    assert sorted(t.range((9, 3), 1.5)) == [(8, 3), (10, 2)]
    assert t.evaluations == 7
    t.evaluations = 0
    assert [x for _, x in t.knn((9, 3), 2)] == [(8, 3), (10, 2)]
    assert t.evaluations == 7


def test_edges() -> None:
    t = MTree(math.dist, 2)
    assert t.range((0, 0), 1) == [] and t.knn((0, 0), 3) == []
    t.insert((1, 1))
    assert t.range((0, 0), 2) == [(1, 1)] and t.knn((0, 0), 0) == []
    for _ in range(9):
        t.insert((1, 1))
    assert check_invariants(t) == 10 and len(t.range((1, 1), 0)) == 10
    try:
        MTree(math.dist, 1)
        raise AssertionError("capacity 1 accepted")
    except ValueError:
        pass


def test_random_against_scan() -> None:
    rng = random.Random(1997)
    for trial in range(500):
        cap = rng.randint(2, 6)
        if trial % 2:
            dim = rng.choice([1, 2, 3])
            pts = [tuple(rng.randint(0, 9) for _ in range(dim)) for _ in range(rng.randint(0, 50))]
            d = math.dist
            q = tuple(rng.uniform(-1, 10) for _ in range(dim))
            r = rng.uniform(0, 5)
        else:
            pts = ["".join(rng.choice("abc") for _ in range(rng.randint(0, 5))) for _ in range(rng.randint(0, 35))]
            d = edit
            q = "".join(rng.choice("abc") for _ in range(rng.randint(0, 5)))
            r = rng.randint(0, 3)
        t = build(pts, d, cap)
        assert check_invariants(t) == len(pts)
        assert sorted(t.range(q, r)) == sorted(x for x in pts if d(q, x) <= r)
        k = rng.randint(1, 6)
        got = [dist for dist, _ in t.knn(q, k)]
        want = sorted(d(q, x) for x in pts)[:k]
        assert got == want

What the next level needs

  • the tests did not run in this build

Read the full page about M-tree