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