Source code for mathematicskit.numerical_analysis.systems.root_finding

"""Scalar root-finding methods: bisection, Newton-Raphson, secant,
fixed-point iteration, Halley's method, and Steffensen's method.

All six are textbook methods; see Burden & Faires, *Numerical Analysis*,
10th ed., Ch. 2 ("Solutions of Equations in One Variable") for the
derivations and convergence-order proofs each docstring below references.
"""

from __future__ import annotations

from typing import Callable, Optional

import numpy as np

from mathematicskit.constants import DEFAULT_MAX_ITER, DEFAULT_RTOL
from mathematicskit.numerical_analysis.core.base import IterativeRootFinder, RootResult

__all__ = ["Bisection", "NewtonRaphson", "Secant", "FixedPointIteration", "Halley", "Steffensen"]


[docs] class Bisection(IterativeRootFinder): """Bisection method on a bracket ``[a, b]`` with ``f(a) * f(b) < 0``. Halves the bracket each iteration, keeping the half where the sign change persists; converges linearly (order 1) with error bound :math:`|x_n - \\text{root}| \\leq (b - a) / 2^{n+1}`. See Burden & Faires, *Numerical Analysis*, 10th ed., Ch. 2.1. Parameters ---------- f : callable Continuous function ``f(x) -> float``. a, b : float Bracket endpoints with ``f(a)`` and ``f(b)`` of opposite sign. tol : float Stop once the bracket width is below `tol`. max_iter : int Maximum number of bisections. Examples -------- >>> result = Bisection(lambda x: x**2 - 2.0, 0.0, 2.0, tol=1e-10).solve() >>> round(result.root, 6) 1.414214 >>> result.converged True >>> # ``history`` holds exactly one midpoint per iteration performed. >>> len(result.history) == result.iterations True """ def __init__(self, f: Callable[[float], float], a: float, b: float, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER): super().__init__(tol=tol, max_iter=max_iter) fa, fb = f(a), f(b) if fa == 0.0: self._root_immediate: Optional[float] = a elif fb == 0.0: self._root_immediate = b else: if fa * fb > 0: raise ValueError("f(a) and f(b) must have opposite signs") self._root_immediate = None self.f = f self.a, self.b = float(a), float(b)
[docs] def solve(self) -> RootResult: if self._root_immediate is not None: return RootResult(root=self._root_immediate, converged=True, iterations=0, history=np.array([self._root_immediate]), method="bisection") a, b = self.a, self.b fa = self.f(a) # The first recorded iterate is produced by the loop below; seeding # ``history`` with the same midpoint here would duplicate it. history: list = [] converged = False n_iter = 0 while n_iter < self.max_iter: n_iter += 1 c = 0.5 * (a + b) fc = self.f(c) history.append(c) if fc == 0.0 or (b - a) / 2.0 < self.tol: # noqa: SIM102 converged = True break if fa * fc < 0: b = c else: a, fa = c, fc return RootResult(root=history[-1], converged=converged, iterations=n_iter, history=np.array(history), method="bisection", extra={"bracket": (a, b)})
[docs] class NewtonRaphson(IterativeRootFinder): """Newton-Raphson method: :math:`x_{n+1} = x_n - f(x_n) / f'(x_n)`. Converges quadratically (order 2) near a simple root, provided :math:`f'` doesn't vanish there. See Burden & Faires, *Numerical Analysis*, 10th ed., Ch. 2.3, Theorem 2.9. Parameters ---------- f, fprime : callable Function and its derivative, both ``float -> float``. x0 : float Initial guess. tol : float Stop once ``|x_{n+1} - x_n| < tol``. max_iter : int Maximum number of iterations. Examples -------- >>> result = NewtonRaphson(lambda x: x**2 - 2.0, lambda x: 2.0 * x, x0=1.0).solve() >>> round(result.root, 10) 1.4142135624 """ def __init__(self, f: Callable[[float], float], fprime: Callable[[float], float], x0: float, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER): super().__init__(tol=tol, max_iter=max_iter) self.f = f self.fprime = fprime self.x0 = float(x0)
[docs] def solve(self) -> RootResult: x = self.x0 history = [x] converged = False n_iter = 0 while n_iter < self.max_iter: n_iter += 1 fpx = self.fprime(x) if fpx == 0.0: raise ZeroDivisionError(f"f'(x) vanished at x={x} before convergence") x_new = x - self.f(x) / fpx history.append(x_new) if abs(x_new - x) < self.tol: x = x_new converged = True break x = x_new return RootResult(root=x, converged=converged, iterations=n_iter, history=np.array(history), method="newton_raphson")
[docs] class Secant(IterativeRootFinder): """Secant method: Newton's method with the derivative replaced by a finite-difference slope through the two most recent iterates. :math:`x_{n+1} = x_n - f(x_n) \\dfrac{x_n - x_{n-1}}{f(x_n) - f(x_{n-1})}`. Converges superlinearly with order :math:`(1+\\sqrt5)/2 \\approx 1.618` (the golden ratio) near a simple root -- see Burden & Faires, *Numerical Analysis*, 10th ed., Ch. 2.3, Theorem 2.10 -- without requiring an analytic derivative. Parameters ---------- f : callable Function ``f(x) -> float``. x0, x1 : float Two initial guesses. tol : float Stop once ``|x_{n+1} - x_n| < tol``. max_iter : int Maximum number of iterations. Examples -------- >>> result = Secant(lambda x: x**2 - 2.0, x0=1.0, x1=2.0).solve() >>> round(result.root, 8) 1.41421356 """ def __init__(self, f: Callable[[float], float], x0: float, x1: float, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER): super().__init__(tol=tol, max_iter=max_iter) self.f = f self.x0, self.x1 = float(x0), float(x1)
[docs] def solve(self) -> RootResult: x_prev, x_curr = self.x0, self.x1 f_prev = self.f(x_prev) history = [x_prev, x_curr] converged = False n_iter = 0 while n_iter < self.max_iter: n_iter += 1 f_curr = self.f(x_curr) denom = f_curr - f_prev if denom == 0.0: raise ZeroDivisionError("f(x_n) - f(x_{n-1}) vanished before convergence") x_new = x_curr - f_curr * (x_curr - x_prev) / denom history.append(x_new) if abs(x_new - x_curr) < self.tol: x_curr = x_new converged = True break x_prev, f_prev = x_curr, f_curr x_curr = x_new return RootResult(root=x_curr, converged=converged, iterations=n_iter, history=np.array(history), method="secant")
[docs] class FixedPointIteration(IterativeRootFinder): """Fixed-point iteration: :math:`x_{n+1} = g(x_n)`, converging to a fixed point ``x* = g(x*)`` (equivalently a root of ``f(x) = g(x) - x``). Converges linearly if ``|g'(x*)| < 1`` in a neighborhood of the fixed point (the contraction condition); see Burden & Faires, *Numerical Analysis*, 10th ed., Ch. 2.2, Theorem 2.4 (Fixed-Point Theorem) and Corollary 2.5. Parameters ---------- g : callable Iteration function ``g(x) -> float``. x0 : float Initial guess. tol : float Stop once ``|x_{n+1} - x_n| < tol``. max_iter : int Maximum number of iterations. Examples -------- >>> # x* solves x = cos(x); g(x) = cos(x) is a contraction near x*. >>> import numpy as np >>> result = FixedPointIteration(np.cos, x0=0.5, tol=1e-10).solve() >>> bool(abs(result.root - np.cos(result.root)) < 1e-9) True """ def __init__(self, g: Callable[[float], float], x0: float, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER): super().__init__(tol=tol, max_iter=max_iter) self.g = g self.x0 = float(x0)
[docs] def solve(self) -> RootResult: x = self.x0 history = [x] converged = False n_iter = 0 while n_iter < self.max_iter: n_iter += 1 x_new = self.g(x) history.append(x_new) if abs(x_new - x) < self.tol: x = x_new converged = True break x = x_new return RootResult(root=x, converged=converged, iterations=n_iter, history=np.array(history), method="fixed_point")
[docs] class Halley(IterativeRootFinder): r"""Halley's method: a cubically convergent refinement of Newton's method. .. math:: x_{n+1} = x_n - \frac{2 f(x_n) f'(x_n)}{2 f'(x_n)^2 - f(x_n) f''(x_n)} Equivalent to applying Newton's method to :math:`f/\sqrt{|f'|}`, or to stepping to the root of the osculating hyperbola at :math:`x_n`. Converges with order 3 near a simple root. See E. Halley, "Methodus nova accurata & facilis inveniendi radices aequationum quarumcumque generaliter, sine praevia reductione," Philosophical Transactions 18 (1694), 136-148; and T. R. Scavo and J. B. Thoo, "On the Geometry of Halley's Method," American Mathematical Monthly 102 (1995), 417-426. The same iteration is what :func:`scipy.optimize.newton` runs when given ``fprime2``; it is hand-rolled here to expose the iterates. Parameters ---------- f, fprime, fprime2 : callable Function and its first and second derivatives, all ``float -> float``. x0 : float Initial guess. tol : float Stop once ``|x_{n+1} - x_n| < tol``. max_iter : int Maximum number of iterations. Examples -------- >>> result = Halley(lambda x: x**2 - 2.0, lambda x: 2.0 * x, lambda x: 2.0, x0=1.0).solve() >>> round(result.root, 12) 1.414213562373 >>> result.iterations <= 4 True """ def __init__( self, f: Callable[[float], float], fprime: Callable[[float], float], fprime2: Callable[[float], float], x0: float, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER, ): super().__init__(tol=tol, max_iter=max_iter) self.f, self.fprime, self.fprime2 = f, fprime, fprime2 self.x0 = float(x0)
[docs] def solve(self) -> RootResult: x = self.x0 history = [x] converged = False n_iter = 0 while n_iter < self.max_iter: n_iter += 1 fx, fpx, fppx = self.f(x), self.fprime(x), self.fprime2(x) if fx == 0.0: converged = True break denom = 2.0 * fpx * fpx - fx * fppx if denom == 0.0: raise ZeroDivisionError(f"Halley denominator vanished at x={x} before convergence") x_new = x - 2.0 * fx * fpx / denom history.append(x_new) if abs(x_new - x) < self.tol: x = x_new converged = True break x = x_new return RootResult(root=float(x), converged=converged, iterations=n_iter, history=np.array(history), method="halley")
[docs] class Steffensen(IterativeRootFinder): r"""Steffensen's method: fixed-point iteration accelerated by Aitken's :math:`\Delta^2` process at every step. From :math:`x_n`, compute :math:`g(x_n)` and :math:`g(g(x_n))`, then jump to their Aitken extrapolation .. math:: x_{n+1} = x_n - \frac{\bigl(g(x_n) - x_n\bigr)^2}{g(g(x_n)) - 2 g(x_n) + x_n}. Converges quadratically to a fixed point with :math:`g'(x^*) \neq 1`, even where plain iteration converges only linearly (or diverges), and without any derivative. See J. F. Steffensen, "Remarks on iteration," Skandinavisk Aktuarietidskrift 16 (1933), 64-72; Burden & Faires, *Numerical Analysis*, 10th ed., Ch. 2.5, Algorithm 2.6. The same iteration is :func:`scipy.optimize.fixed_point` with ``method="del2"``; it is hand-rolled here to expose the iterates. Parameters ---------- g : callable Iteration function ``g(x) -> float`` whose fixed point is sought. x0 : float Initial guess. tol : float Stop once ``|x_{n+1} - x_n| < tol``. max_iter : int Maximum number of iterations. Examples -------- >>> import numpy as np >>> result = Steffensen(np.cos, x0=0.5, tol=1e-12).solve() >>> round(result.root, 10) 0.7390851332 >>> result.iterations < 6 True """ def __init__(self, g: Callable[[float], float], x0: float, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER): super().__init__(tol=tol, max_iter=max_iter) self.g = g self.x0 = float(x0)
[docs] def solve(self) -> RootResult: x = self.x0 history = [x] converged = False n_iter = 0 while n_iter < self.max_iter: n_iter += 1 x1 = self.g(x) x2 = self.g(x1) denom = x2 - 2.0 * x1 + x if denom == 0.0: # Already at the fixed point to working precision. converged = abs(x1 - x) < self.tol x = x2 break x_new = x - (x1 - x) ** 2 / denom history.append(x_new) if abs(x_new - x) < self.tol: x = x_new converged = True break x = x_new return RootResult(root=float(x), converged=converged, iterations=n_iter, history=np.array(history), method="steffensen")