Algorithm Search⌘K

Interval branch and bound in Python

Global optimisation by bisecting boxes and bounding the objective with interval arithmetic.

File
interval_branch_and_bound.py, 137 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
interval_branch_and_bound.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/interval-branch-and-bound/. The authors are listed in prov/AGSJXC.jsonl.
"""Interval branch and bound for global minimization over a box.

All arithmetic is exact, with fractions, so the interval bounds need no
outward rounding. The objective is a Python function that works both on
numbers and on Interval objects: on numbers it gives f, on intervals it gives
the natural interval extension F.
"""

from __future__ import annotations

import heapq
from fractions import Fraction
from typing import Callable, Optional, Sequence, Union

Num = Union[int, Fraction]


class Interval:
    """A closed interval [lo, hi] of rationals."""

    __slots__ = ("lo", "hi")

    def __init__(self, lo: Num, hi: Optional[Num] = None) -> None:
        self.lo = Fraction(lo)
        self.hi = Fraction(lo if hi is None else hi)
        if self.lo > self.hi:
            raise ValueError("empty interval")

    @staticmethod
    def of(x: Union["Interval", Num]) -> "Interval":
        return x if isinstance(x, Interval) else Interval(x)

    def __add__(self, other: Union["Interval", Num]) -> "Interval":
        o = Interval.of(other)
        return Interval(self.lo + o.lo, self.hi + o.hi)

    __radd__ = __add__

    def __neg__(self) -> "Interval":
        return Interval(-self.hi, -self.lo)

    def __sub__(self, other: Union["Interval", Num]) -> "Interval":
        return self + (-Interval.of(other))

    def __rsub__(self, other: Num) -> "Interval":
        return Interval.of(other) + (-self)

    def __mul__(self, other: Union["Interval", Num]) -> "Interval":
        o = Interval.of(other)
        p = (self.lo * o.lo, self.lo * o.hi, self.hi * o.lo, self.hi * o.hi)
        return Interval(min(p), max(p))

    __rmul__ = __mul__

    def __pow__(self, k: int) -> "Interval":
        """Power with a nonnegative integer exponent; even powers are not below 0."""
        if k < 0:
            raise ValueError("negative exponent")
        if k == 0:
            return Interval(1)
        a, b = self.lo ** k, self.hi ** k
        if k % 2 == 1:
            return Interval(a, b)
        if self.lo <= 0 <= self.hi:
            return Interval(0, max(a, b))
        return Interval(min(a, b), max(a, b))

    def __repr__(self) -> str:
        return "[%s, %s]" % (self.lo, self.hi)


Box = tuple[tuple[Fraction, Fraction], ...]


def width(box: Box) -> Fraction:
    return max(hi - lo for lo, hi in box)


def midpoint(box: Box) -> list[Fraction]:
    return [(lo + hi) / 2 for lo, hi in box]


def lower(func: Callable, box: Box) -> Fraction:
    return Interval.of(func([Interval(lo, hi) for lo, hi in box])).lo


def interval_branch_and_bound(
    func: Callable,
    box: Sequence[tuple[Num, Num]],
    eps: Num,
    stats: Optional[dict[str, int]] = None,
) -> tuple[Fraction, Fraction, list[Fraction], list[tuple[Fraction, Box]]]:
    """Enclose the global minimum of func over box.

    Return (lo, ub, xbest, boxes): lo <= min f <= ub = f(xbest), and every
    global minimizer lies in one of the boxes, each given with its lower bound.
    Every returned box has width at most eps.
    """
    eps = Fraction(eps)
    if eps <= 0:
        raise ValueError("eps must be positive")
    x0: Box = tuple((Fraction(lo), Fraction(hi)) for lo, hi in box)
    if stats is not None:
        stats.update(boxes=1, split=0)
    xbest = midpoint(x0)  # @line L1
    ub = Fraction(func(xbest))
    heap: list[tuple[Fraction, int, Box]] = [(lower(func, x0), 0, x0)]
    tick = 0
    result: list[tuple[Fraction, Box]] = []
    while heap:  # @line L2
        b, _, y = heapq.heappop(heap)  # @line L3
        if b > ub:  # @line L4
            break
        if width(y) <= eps:  # @line L5
            result.append((b, y))
            continue
        if stats is not None:
            stats["split"] += 1
        k = max(range(len(y)), key=lambda i: (y[i][1] - y[i][0], -i))  # @line L6
        lo, hi = y[k]
        mid = (lo + hi) / 2
        halves = (y[:k] + ((lo, mid),) + y[k + 1:], y[:k] + ((mid, hi),) + y[k + 1:])
        for z in halves:  # @line L7
            xm = midpoint(z)
            fm = Fraction(func(xm))
            if fm < ub:  # @line L8
                ub, xbest = fm, xm
            bz = lower(func, z)
            if bz <= ub:  # @line L9
                tick += 1
                heapq.heappush(heap, (bz, tick, z))
                if stats is not None:
                    stats["boxes"] += 1
    result = [(b, y) for b, y in result if b <= ub]  # @line L10
    return min(b for b, _ in result), ub, xbest, result
test_interval_branch_and_bound.py 104 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/interval-branch-and-bound/. The authors are listed in prov/AGSJXC.jsonl.

import random
from fractions import Fraction as F

import pytest

from interval_branch_and_bound import Interval, interval_branch_and_bound


def g(x):
    return (x[0] ** 2 - 2) ** 2 + x[0] - 4


def g_expanded(x):
    return x[0] ** 4 - 4 * x[0] ** 2 + x[0]


def inside(point, boxes):
    return any(all(lo <= p <= hi for p, (lo, hi) in zip(point, y)) for _, y in boxes)


def test_example_of_the_page() -> None:
    stats: dict[str, int] = {}
    lo, ub, xbest, boxes = interval_branch_and_bound(g, [(-3, 3)], F(1, 2), stats)
    assert (lo, ub, xbest) == (F(-93, 16), F(-87, 16), [F(-3, 2)])
    assert boxes == [(F(-93, 16), ((F(-15, 8), F(-3, 2)),)), (F(-11, 2), ((F(-3, 2), F(-9, 8)),))]
    assert stats == {"boxes": 8, "split": 6}


def test_expanded_form_is_looser() -> None:
    stats: dict[str, int] = {}
    lo, ub, _, boxes = interval_branch_and_bound(g_expanded, [(-3, 3)], F(1, 2), stats)
    assert lo == F(-87, 8) and ub == F(-87, 16)
    assert len(boxes) == 7 and stats == {"boxes": 20, "split": 13}


def test_small_eps_encloses_the_minimum() -> None:
    lo, ub, _, boxes = interval_branch_and_bound(g, [(-3, 3)], F(1, 1024))
    # The minimum is about -5.4441920 at x about -1.47299.
    assert lo <= F(-54441920, 10 ** 7) <= ub and ub - lo < F(1, 1000)
    assert inside([F(-147299, 100000)], boxes)


def test_interval_arithmetic() -> None:
    a = Interval(-1, 2)
    assert (a * a).lo == -2 and (a ** 2).lo == 0 and (a ** 2).hi == 4
    assert (a ** 3).lo == -1 and (a ** 3).hi == 8
    assert (3 - a).lo == 1 and (3 - a).hi == 4
    assert (Interval(-3, -2) ** 2).lo == 4
    with pytest.raises(ValueError):
        interval_branch_and_bound(g, [(0, 1)], 0)


def test_degenerate_box_and_constant() -> None:
    lo, ub, xbest, boxes = interval_branch_and_bound(g, [(1, 1)], F(1, 4))
    assert lo == ub == -2 and xbest == [1]
    lo, ub, _, boxes = interval_branch_and_bound(lambda x: 7 + 0 * x[0], [(-1, 1)], F(1, 2))
    assert lo == ub == 7


def test_two_global_minimizers() -> None:
    rng = random.Random(1979)
    for _ in range(300):
        a = F(rng.randint(-15, 15), 8)
        b = F(rng.randint(-15, 15), 8)
        c = F(rng.randint(-20, 20), 3)
        f = lambda x, a=a, b=b, c=c: (x[0] - a) ** 2 * (x[0] - b) ** 2 + c
        lo, ub, xbest, boxes = interval_branch_and_bound(f, [(-2, 2)], F(1, 8))
        assert lo <= c <= ub == f(xbest)
        assert inside([a], boxes) and inside([b], boxes)
        assert all(w <= ub for w, _ in boxes)


def test_two_dimensional_bowls() -> None:
    rng = random.Random(1980)
    for _ in range(150):
        a = F(rng.randint(-7, 7), 4)
        b = F(rng.randint(-7, 7), 4)
        c = rng.randint(-5, 5)
        f = lambda x, a=a, b=b, c=c: (x[0] - a) ** 2 + 2 * (x[1] - b) ** 2 + c + (x[0] - a) * (x[1] - b)
        lo, ub, xbest, boxes = interval_branch_and_bound(f, [(-2, 2), (-2, 2)], F(1, 4))
        assert lo <= c <= ub == f(xbest)
        assert inside([a, b], boxes)


def test_random_polynomials_against_grid() -> None:
    rng = random.Random(1966)
    for _ in range(150):
        coef = [F(rng.randint(-9, 9), rng.randint(1, 3)) for _ in range(rng.randint(1, 6))]

        def f(x, coef=coef):
            return sum((ck * x[0] ** k for k, ck in enumerate(coef)), F(0))

        lo, ub, xbest, boxes = interval_branch_and_bound(f, [(-2, 2)], F(1, 8))
        grid = [F(-2) + F(i, 50) for i in range(201)]
        values = [f([t]) for t in grid]
        assert lo <= min(values)
        assert ub == f(xbest) and -2 <= xbest[0] <= 2
        # A point outside every returned box lies in a discarded box, so its value is above the minimum.
        assert all(v > lo for t, v in zip(grid, values) if not inside([t], boxes))
        lo2, ub2, _, _ = interval_branch_and_bound(f, [(-2, 2)], F(1, 32))
        assert max(lo, lo2) <= min(ub, ub2, min(values))

What the next level needs

  • the tests did not run in this build

Read the full page about Interval branch and bound