Source code for mathematicskit.optimization.systems.momentum

r"""Momentum-based first-order methods: Nesterov's accelerated gradient and Adam.

Hand-rolled to expose the per-iterate path (neither is in
``scipy.optimize``).

- Y. E. Nesterov, "A Method of Solving a Convex Programming Problem with
  Convergence Rate :math:`O(1/k^2)`," Soviet Mathematics Doklady 27(2)
  (1983), 372-376.
- D. P. Kingma and J. Ba, "Adam: A Method for Stochastic Optimization,"
  3rd International Conference on Learning Representations (ICLR 2015),
  arXiv:1412.6980.
"""

from __future__ import annotations

from typing import Callable

import numpy as np

from mathematicskit.constants import DEFAULT_MAX_ITER, DEFAULT_RTOL
from mathematicskit.optimization.core.base import OptimizeResult, UnconstrainedOptimizer

__all__ = ["NesterovAcceleratedGradient", "Adam"]


[docs] class NesterovAcceleratedGradient(UnconstrainedOptimizer): r"""Nesterov's accelerated gradient method (1983). Takes the gradient step from an extrapolated point: .. math:: y_k = x_k + \frac{k-1}{k+2}(x_k - x_{k-1}), \qquad x_{k+1} = y_k - \alpha \nabla f(y_k). For convex ``f`` with ``L``-Lipschitz gradient and :math:`\alpha = 1/L`, :math:`f(x_k) - f^* = O(1/k^2)`, against :math:`O(1/k)` for plain gradient descent -- the optimal rate for any first-order method. Parameters ---------- alpha : float Step size (``1/L`` is the classical choice). tol : float Convergence tolerance on ``||grad(x)||``. max_iter : int Examples -------- >>> import numpy as np >>> f = lambda x: 0.5 * (x[0] ** 2 + 100.0 * x[1] ** 2) >>> grad = lambda x: np.array([x[0], 100.0 * x[1]]) >>> result = NesterovAcceleratedGradient(alpha=0.01, tol=1e-8, max_iter=5000).minimize(f, grad, np.array([1.0, 1.0])) >>> np.allclose(result.x, [0.0, 0.0], atol=1e-6) True """ def __init__(self, alpha: float = 0.1, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER): super().__init__(tol=tol, max_iter=max_iter) self.alpha = float(alpha)
[docs] def minimize(self, f: Callable[[np.ndarray], float], grad: Callable[[np.ndarray], np.ndarray], x0: np.ndarray) -> OptimizeResult: x = np.asarray(x0, dtype=np.float64).copy() x_prev = x.copy() path = [x.copy()] converged = False it = 0 while it < self.max_iter: if np.linalg.norm(grad(x)) < self.tol: converged = True break it += 1 y = x + ((it - 1.0) / (it + 2.0)) * (x - x_prev) x_prev = x x = y - self.alpha * grad(y) path.append(x.copy()) return OptimizeResult(x=x, fun=float(f(x)), path=np.array(path), iterations=it, converged=converged, method="nesterov")
[docs] class Adam(UnconstrainedOptimizer): r"""Adam: adaptive moment estimation (Kingma and Ba, 2014). Keeps exponential moving averages of the gradient and of its elementwise square, corrects their bias toward zero, and scales each coordinate's step by the root-mean-square gradient: .. math:: m_k = \beta_1 m_{k-1} + (1-\beta_1) g_k, \qquad v_k = \beta_2 v_{k-1} + (1-\beta_2) g_k^2, .. math:: x_{k+1} = x_k - \alpha \frac{m_k / (1-\beta_1^k)}{\sqrt{v_k/(1-\beta_2^k)} + \varepsilon}. Each coordinate moves by roughly ``alpha`` per step regardless of the gradient's scale, which makes Adam robust to badly scaled and noisy (stochastic) gradients. With a constant ``alpha`` it settles within about ``alpha`` of a minimizer rather than converging exactly. Parameters ---------- alpha : float Step size. beta1, beta2 : float Decay rates of the first- and second-moment estimates. eps : float Denominator safeguard. tol : float Convergence tolerance on ``||grad(x)||``. max_iter : int Examples -------- >>> import numpy as np >>> f = lambda x: (x[0] - 1.0) ** 2 + 1000.0 * (x[1] + 2.0) ** 2 >>> grad = lambda x: np.array([2.0 * (x[0] - 1.0), 2000.0 * (x[1] + 2.0)]) >>> result = Adam(alpha=0.05, max_iter=3000).minimize(f, grad, np.array([0.0, 0.0])) >>> np.allclose(result.x, [1.0, -2.0], atol=1e-2) True """ def __init__( self, alpha: float = 1e-3, beta1: float = 0.9, beta2: float = 0.999, eps: float = 1e-8, tol: float = DEFAULT_RTOL, max_iter: int = DEFAULT_MAX_ITER ): super().__init__(tol=tol, max_iter=max_iter) self.alpha = float(alpha) self.beta1 = float(beta1) self.beta2 = float(beta2) self.eps = float(eps)
[docs] def minimize(self, f: Callable[[np.ndarray], float], grad: Callable[[np.ndarray], np.ndarray], x0: np.ndarray) -> OptimizeResult: x = np.asarray(x0, dtype=np.float64).copy() m = np.zeros_like(x) v = np.zeros_like(x) path = [x.copy()] converged = False it = 0 while it < self.max_iter: g = np.asarray(grad(x), dtype=np.float64) if np.linalg.norm(g) < self.tol: converged = True break it += 1 m = self.beta1 * m + (1.0 - self.beta1) * g v = self.beta2 * v + (1.0 - self.beta2) * g * g m_hat = m / (1.0 - self.beta1**it) v_hat = v / (1.0 - self.beta2**it) x = x - self.alpha * m_hat / (np.sqrt(v_hat) + self.eps) path.append(x.copy()) return OptimizeResult(x=x, fun=float(f(x)), path=np.array(path), iterations=it, converged=converged, method="adam")