Source code for mathematicskit.optimization.systems.interior_point

r"""Interior-point linear programming: a primal-dual path-following method.

Karmarkar (1984) gave the first polynomial-time LP algorithm that was
also fast in practice. Instead of walking the polytope's edges like the
simplex method, it moves through the interior. Its modern descendants,
the primal-dual methods of Kojima, Mizuno and Yoshise (1989) and
Mehrotra (1992), follow the *central path*: the solutions of the
perturbed optimality conditions

.. math::

   A x = b, \qquad A^T y + s = c, \qquad x_i s_i = \mu, \qquad x, s > 0,

as :math:`\mu \to 0`. Each iteration takes one Newton step towards the
point with target :math:`\sigma\mu` (centring parameter
:math:`\sigma < 1`) and stops short of the boundary. Hand-rolled to
expose that path; :func:`~mathematicskit.optimization.systems.linear_programming.linear_program`
with ``method="highs-ipm"`` is the production solver to check against.
See N. Karmarkar, "A New Polynomial-Time Algorithm for Linear
Programming," Combinatorica 4(4) (1984), 373-395, and S. J. Wright,
*Primal-Dual Interior-Point Methods* (SIAM, 1997), Ch. 1 and 5.
"""

from __future__ import annotations

import numpy as np

from mathematicskit.constants import DEFAULT_MAX_ITER
from mathematicskit.optimization.core.base import OptimizeResult

__all__ = ["interior_point_lp"]


def _step_to_boundary(v: np.ndarray, dv: np.ndarray) -> float:
    shrinking = dv < 0
    return float(min(1.0, np.min(-v[shrinking] / dv[shrinking]))) if np.any(shrinking) else 1.0


[docs] def interior_point_lp( c: np.ndarray, a_ub: np.ndarray, b_ub: np.ndarray, sigma: float = 0.1, eta: float = 0.95, tol: float = 1e-9, max_iter: int = DEFAULT_MAX_ITER, ) -> OptimizeResult: r"""Solve :math:`\min c^T x` s.t. :math:`A x \leq b`, :math:`x \geq 0` by primal-dual path following. Slack variables put the problem in standard form :math:`[A\ I]\,(x, w) = b`. From the interior point :math:`x = w = s = 1`, :math:`y = 0` (not necessarily feasible), each iteration solves the Newton system for the central-path equations with target :math:`\sigma\mu`, through the normal equations :math:`A D A^T \Delta y = r` with :math:`D = \mathrm{diag}(x/s)`, then steps a fraction ``eta`` of the way to the boundary of the positive orthant. Parameters ---------- c : ndarray, shape (n,) a_ub : ndarray, shape (m, n) b_ub : ndarray, shape (m,) sigma : float Centring parameter in ``(0, 1)``: smaller is more aggressive, larger stays closer to the central path. eta : float Fraction of the step to the boundary actually taken, in ``(0, 1)``. tol : float Stop when the duality measure :math:`\mu = x^T s / N` and the relative primal and dual residuals all fall below ``tol``. max_iter : int Returns ------- OptimizeResult ``path`` holds the original variables :math:`x` at every iterate, all strictly inside the feasible region once primal feasibility is reached. ``extra`` has ``"duality_measure"`` (the :math:`\mu` history) and ``"dual"`` (the multipliers :math:`y` of the inequality constraints, :math:`\leq 0` at the optimum). Examples -------- >>> import numpy as np >>> # maximize 3x + 5y s.t. x + 2y <= 40, 2x + y <= 30: optimum (20/3, 50/3). >>> result = interior_point_lp(np.array([-3.0, -5.0]), np.array([[1.0, 2.0], [2.0, 1.0]]), np.array([40.0, 30.0])) >>> result.converged, np.round(result.x, 6), round(-result.fun, 6) (True, array([ 6.666667, 16.666667]), 103.333333) """ c = np.asarray(c, dtype=np.float64) a_ub = np.atleast_2d(np.asarray(a_ub, dtype=np.float64)) b = np.asarray(b_ub, dtype=np.float64) m, n = a_ub.shape a = np.hstack([a_ub, np.eye(m)]) cost = np.concatenate([c, np.zeros(m)]) big_n = n + m x, s, y = np.ones(big_n), np.ones(big_n), np.zeros(m) path, mus = [x[:n].copy()], [] converged = False iterations = 0 for iterations in range(1, int(max_iter) + 1): r_primal = a @ x - b r_dual = a.T @ y + s - cost mu = float(x @ s / big_n) mus.append(mu) if mu < tol and np.linalg.norm(r_primal) <= tol * (1 + np.linalg.norm(b)) and np.linalg.norm(r_dual) <= tol * (1 + np.linalg.norm(cost)): converged = True iterations -= 1 break d = x / s target = sigma * mu / s rhs = -r_primal + a @ (x - target) - a @ (d * r_dual) dy = np.linalg.solve((a * d) @ a.T, rhs) ds = -r_dual - a.T @ dy dx = -x + target - d * ds alpha_primal = eta * _step_to_boundary(x, dx) alpha_dual = eta * _step_to_boundary(s, ds) x = x + alpha_primal * dx y = y + alpha_dual * dy s = s + alpha_dual * ds path.append(x[:n].copy()) return OptimizeResult( x=x[:n], fun=float(c @ x[:n]), path=np.array(path), iterations=iterations, converged=converged, method="interior_point", extra={"duality_measure": np.array(mus), "dual": y}, )