Interval Newton method in Python
Newton iteration on intervals that gives guaranteed enclosures of all roots in a box. A box that maps into itself provably holds exactly one root, and widths then shrink quadratically.
- File
interval_newton_method.py, 214 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-newton-method/. The authors are listed in prov/MES2VT.jsonl.
"""The interval Newton method: verified enclosures of all zeros of f in [a, b].
f and its derivative df are written with ordinary operators, so that called
on an Interval they return an enclosure of the range (their natural interval
extension). Interval arithmetic here rounds outward by one ulp after every
operation with math.nextafter: round to nearest is off by at most half an ulp,
so the result encloses the exact one. iexp assumes math.exp is accurate to
within one ulp, which holds for common C libraries but is not guaranteed by
IEEE 754.
The Newton operator is N(X) = m - f([m]) / f'(X) with m the midpoint. Every
zero of f in X lies in N(X); if 0 is not in f'(X) and N(X) is inside X, then
X holds exactly one zero.
"""
import math
from collections import namedtuple
INF = math.inf
Box = namedtuple("Box", "lo hi status") # status: "unique" or "possible"
NewtonResult = namedtuple("NewtonResult", "boxes evals iterations status")
def _dn(x):
return math.nextafter(x, -INF)
def _up(x):
return math.nextafter(x, INF)
class Interval:
__slots__ = ("lo", "hi")
def __init__(self, lo, hi=None):
hi = lo if hi is None else hi
if math.isnan(lo) or math.isnan(hi) or lo > hi:
raise ValueError("invalid interval")
self.lo, self.hi = float(lo), float(hi)
@staticmethod
def _c(x):
return x if isinstance(x, Interval) else Interval(x)
def __add__(self, o):
o = Interval._c(o)
return Interval(_dn(self.lo + o.lo), _up(self.hi + o.hi))
__radd__ = __add__
def __neg__(self):
return Interval(-self.hi, -self.lo)
def __sub__(self, o):
return self + (-Interval._c(o))
def __rsub__(self, o):
return Interval._c(o) + (-self)
def __mul__(self, o):
o = Interval._c(o)
p = [self.lo * o.lo, self.lo * o.hi, self.hi * o.lo, self.hi * o.hi]
return Interval(_dn(min(p)), _up(max(p)))
__rmul__ = __mul__
def __truediv__(self, o):
o = Interval._c(o)
if o.lo <= 0 <= o.hi:
raise ZeroDivisionError("divisor interval contains 0")
q = [self.lo / o.lo, self.lo / o.hi, self.hi / o.lo, self.hi / o.hi]
return Interval(_dn(min(q)), _up(max(q)))
def __rtruediv__(self, o):
return Interval._c(o) / self
def __pow__(self, k):
if not isinstance(k, int) or k < 0:
raise ValueError("only nonnegative integer powers")
if k == 0:
return Interval(1.0)
r = self
for _ in range(k - 1):
r = r * self
if k % 2 == 0 and self.lo < 0 < self.hi:
r = Interval(0.0, r.hi) # an even power is nonnegative
elif k % 2 == 0:
r = Interval(max(r.lo, 0.0), r.hi)
return r
def contains(self, x):
return self.lo <= x <= self.hi
def width(self):
return self.hi - self.lo
def __repr__(self):
return f"Interval({self.lo!r}, {self.hi!r})"
def iexp(x):
if isinstance(x, Interval):
return Interval(max(0.0, _dn(_dn(math.exp(x.lo)))), _up(_up(math.exp(x.hi))))
return math.exp(x)
def newton_operator(f, df, X):
"""N(X) intersected with X: a list of 0, 1 or 2 intervals, and a flag that
says whether X was proved to hold exactly one zero."""
m = 0.5 * X.lo + 0.5 * X.hi
fm = f(Interval(m))
D = df(X)
if not (D.lo <= 0 <= D.hi):
q = fm / D
N = Interval(_dn(m - q.hi), _up(m - q.lo))
lo, hi = max(N.lo, X.lo), min(N.hi, X.hi)
unique = X.lo <= N.lo and N.hi <= X.hi
return ([Interval(lo, hi)] if lo <= hi else []), unique
if fm.lo <= 0 <= fm.hi:
return [X], False # no information: N(X) is the whole line
# extended division: fm / D with 0 in D gives at most two half-lines
pieces = []
if fm.lo > 0:
u = fm.lo
if D.hi > 0: # q >= u / D.hi, so m - q <= m - u / D.hi
pieces.append((-INF, _up(m - _dn(u / D.hi))))
if D.lo < 0: # q <= u / D.lo, so m - q >= m - u / D.lo
pieces.append((_dn(m - _up(u / D.lo)), INF))
else:
v = fm.hi
if D.hi > 0: # q <= v / D.hi
pieces.append((_dn(m - _up(v / D.hi)), INF))
if D.lo < 0: # q >= v / D.lo
pieces.append((-INF, _up(m - _dn(v / D.lo))))
out = []
for a, b in pieces:
lo, hi = max(a, X.lo), min(b, X.hi)
if lo <= hi:
out.append(Interval(lo, hi))
out.sort(key=lambda I: I.lo)
return out, False
def interval_newton(f, df, a, b, tol=1e-12, max_iter=10000):
if not (math.isfinite(a) and math.isfinite(b)) or a > b:
raise ValueError("need a finite interval a <= b")
stack, found = [Interval(a, b)], [] # @line L1
evals = it = 0
while stack: # @line L2
if it >= max_iter: # @line L11
rest = [Box(X.lo, X.hi, "possible") for X in stack]
return NewtonResult(_merge(f, df, found + rest), evals, it, "max-iter")
it += 1
X = stack.pop() # @line L3
evals += 1
if not f(X).contains(0.0): # @line L4
continue
pieces, unique = newton_operator(f, df, X) # @line L5
evals += 2
mid = 0.5 * X.lo + 0.5 * X.hi
small = (X.width() <= tol * max(1.0, abs(X.lo), abs(X.hi))
or not X.lo < mid < X.hi) # cannot be split any more
if unique: # @line L6
Y = pieces[0]
if small or Y.width() >= X.width(): # @line L7
found.append(Box(Y.lo, Y.hi, "unique"))
else:
stack.append(Y)
elif small: # @line L8
found.append(Box(X.lo, X.hi, "possible"))
elif len(pieces) == 1 and pieces[0].width() <= 0.5 * X.width(): # @line L9
stack.append(pieces[0])
else:
for Y in pieces: # @line L10
c = 0.5 * Y.lo + 0.5 * Y.hi
if Y.width() > 0.5 * X.width() and Y.lo < c < Y.hi:
stack.extend([Interval(c, Y.hi), Interval(Y.lo, c)])
else:
stack.append(Y)
return NewtonResult(_merge(f, df, found), evals, it, "done") # @line L12
def _verify(f, df, lo, hi):
"""Try to prove that [lo, hi], or a slightly inflated box around it, holds
exactly one zero (a zero on the edge of a box cannot be verified without
inflation). Returns (lo, hi, status)."""
w = hi - lo
inflated = Interval(_dn(_dn(lo - w)), _up(_up(hi + w)))
for Z in (Interval(lo, hi), inflated):
for _ in range(4):
pieces, unique = newton_operator(f, df, Z)
if unique:
return pieces[0].lo, pieces[0].hi, "unique"
if len(pieces) != 1 or pieces[0].width() >= Z.width():
break
Z = pieces[0]
return lo, hi, "possible"
def _merge(f, df, boxes):
"""Join overlapping boxes (a zero on a split point lands in two boxes) and
check each union for a unique zero with a few Newton tests."""
boxes = sorted(boxes, key=lambda B: B.lo)
joined = []
for B in boxes:
if joined and B.lo <= joined[-1][1]:
lo, hi, k = joined.pop()
joined.append((lo, max(hi, B.hi), k + 1))
else:
joined.append((B.lo, B.hi, 1))
return [Box(*_verify(f, df, lo, hi)) for lo, hi, _ in joined]
test_interval_newton_method.py 147 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/interval-newton-method/. The authors are listed in prov/MES2VT.jsonl.
import math
import random
import unittest
from fractions import Fraction
from interval_newton_method import Interval, iexp, interval_newton, newton_operator
def sq2(x):
return x ** 2 - 2
def dsq2(x):
return 2 * x
class TestIntervalArithmetic(unittest.TestCase):
def test_outward_rounding_encloses_exact(self):
rng = random.Random(3)
for _ in range(500):
a, b = sorted(rng.uniform(-10, 10) for _ in range(2))
c, d = sorted(rng.uniform(-10, 10) for _ in range(2))
X, Y = Interval(a, b), Interval(c, d)
for op in ("add", "sub", "mul"):
Z = {"add": X + Y, "sub": X - Y, "mul": X * Y}[op]
for x in (a, b):
for y in (c, d):
ex = {"add": Fraction(x) + Fraction(y), "sub": Fraction(x) - Fraction(y),
"mul": Fraction(x) * Fraction(y)}[op]
self.assertTrue(Fraction(Z.lo) <= ex <= Fraction(Z.hi))
def test_even_power_and_division(self):
self.assertEqual((Interval(-2, 1) ** 2).lo, 0.0)
with self.assertRaises(ZeroDivisionError):
Interval(1, 2) / Interval(-1, 1)
with self.assertRaises(ValueError):
Interval(float("nan"), 1)
class TestIntervalNewton(unittest.TestCase):
def test_sqrt2_verified(self):
r = interval_newton(sq2, dsq2, 0, 3)
self.assertEqual(r.status, "done")
self.assertEqual(len(r.boxes), 1)
B = r.boxes[0]
self.assertEqual(B.status, "unique")
self.assertTrue(B.lo <= math.sqrt(2) <= B.hi)
self.assertLess(B.hi - B.lo, 1e-15)
# exact check: lo^2 < 2 < hi^2
self.assertTrue(Fraction(B.lo) ** 2 < 2 < Fraction(B.hi) ** 2)
def test_quadratic_contraction(self):
X, widths = Interval(1, 2), [1.0]
for _ in range(4):
pieces, unique = newton_operator(sq2, dsq2, X)
self.assertTrue(unique)
X = pieces[0]
widths.append(X.width())
self.assertLess(widths[3], 1e-8)
for k in range(1, 3):
self.assertLess(widths[k + 1], 0.1 * widths[k] ** 2)
def test_zero_derivative_inside_splits(self):
# f' = 2x vanishes in [-3, 3]: extended division gives two pieces
pieces, unique = newton_operator(sq2, dsq2, Interval(-3, 3))
self.assertFalse(unique)
self.assertEqual(len(pieces), 2)
r = interval_newton(sq2, dsq2, -3, 3)
self.assertEqual([B.status for B in r.boxes], ["unique", "unique"])
self.assertTrue(r.boxes[0].lo <= -math.sqrt(2) <= r.boxes[0].hi)
def test_three_roots_and_eval_count(self):
f = lambda x: (x - 1) * (x - 2) * (x - 3)
df = lambda x: 3 * x ** 2 - 12 * x + 11
r = interval_newton(f, df, -10, 10)
self.assertEqual([B.status for B in r.boxes], ["unique"] * 3)
for B, z in zip(r.boxes, (1, 2, 3)):
self.assertTrue(B.lo <= z <= B.hi)
self.assertEqual(r.evals, 101)
self.assertEqual(r.iterations, 41)
def test_roots_on_split_points(self):
# 0, 1 and -1 are midpoints of boxes: each falls on the edge of two boxes
r = interval_newton(lambda x: x ** 3 - x, lambda x: 3 * x ** 2 - 1, -2, 2)
self.assertEqual([B.status for B in r.boxes], ["unique"] * 3)
self.assertTrue(r.boxes[1].lo <= 0 <= r.boxes[1].hi)
def test_root_at_endpoint(self):
r = interval_newton(lambda x: (x - 1) * (x - 3), lambda x: 2 * x - 4, 1, 2)
self.assertEqual(len(r.boxes), 1)
self.assertEqual(r.boxes[0].status, "unique")
self.assertTrue(r.boxes[0].lo <= 1 <= r.boxes[0].hi)
def test_no_root_is_a_proof(self):
r = interval_newton(lambda x: x ** 2 + 1, lambda x: 2 * x, -5, 5)
self.assertEqual(r.boxes, [])
self.assertEqual(r.evals, 1)
def test_double_root_is_possible_only(self):
r = interval_newton(lambda x: (x - 1) ** 2, lambda x: 2 * (x - 1), 0, 3)
self.assertEqual(len(r.boxes), 1)
B = r.boxes[0]
self.assertEqual(B.status, "possible")
self.assertTrue(B.lo <= 1 <= B.hi)
self.assertLess(B.hi - B.lo, 1e-11)
def test_exp(self):
r = interval_newton(lambda x: iexp(x) - 2, iexp, -5, 5)
B = r.boxes[0]
self.assertEqual(B.status, "unique")
self.assertTrue(B.lo <= math.log(2) <= B.hi)
def test_max_iter_and_bad_input(self):
r = interval_newton(sq2, dsq2, 0, 3, max_iter=3)
self.assertEqual(r.status, "max-iter")
self.assertEqual(r.iterations, 3)
self.assertTrue(any(B.lo <= math.sqrt(2) <= B.hi for B in r.boxes))
with self.assertRaises(ValueError):
interval_newton(sq2, dsq2, 0, math.inf)
with self.assertRaises(ValueError):
interval_newton(sq2, dsq2, float("nan"), 1)
with self.assertRaises(ValueError):
interval_newton(sq2, dsq2, 2, 1)
def test_random_cubics(self):
rng = random.Random(11)
for _ in range(500):
while True:
z = sorted(rng.uniform(-5, 5) for _ in range(3))
if z[1] - z[0] > 0.05 and z[2] - z[1] > 0.05:
break
f = lambda x, z=z: (x - z[0]) * (x - z[1]) * (x - z[2])
df = lambda x, z=z: ((x - z[1]) * (x - z[2]) + (x - z[0]) * (x - z[2])
+ (x - z[0]) * (x - z[1]))
r = interval_newton(f, df, -6, 6)
self.assertEqual(len(r.boxes), 3)
for B, root in zip(r.boxes, z):
self.assertEqual(B.status, "unique")
self.assertTrue(B.lo <= root <= B.hi)
self.assertLess(B.hi - B.lo, 1e-13)
if __name__ == "__main__":
unittest.main()
What the next level needs
- the tests did not run in this build