Source code for mathematicskit.optimization.systems.stochastic
r"""Stochastic approximation: the Robbins-Monro iteration.
Robbins and Monro (1951) showed how to find a root of a function
:math:`M(x)` that can only be observed through noise. Applied to a noisy
gradient :math:`Y_k = \nabla f(x_k) + \varepsilon_k`, the iteration
.. math::
x_{k+1} = x_k - a_k Y_k, \qquad \sum_k a_k = \infty, \quad \sum_k a_k^2 < \infty,
is stochastic gradient descent. Hand-rolled: the iteration itself is the
subject, and SciPy has no equivalent. See H. Robbins and S. Monro, "A
Stochastic Approximation Method," The Annals of Mathematical Statistics
22(3) (1951), 400-407.
"""
from __future__ import annotations
from typing import Callable, Optional
import numpy as np
from mathematicskit.optimization.core.base import OptimizeResult
__all__ = ["robbins_monro"]
[docs]
def robbins_monro(
noisy_grad: Callable[[np.ndarray, np.random.Generator], np.ndarray],
x0,
a0: float = 1.0,
n_iter: int = 1000,
seed: Optional[int] = None,
f: Optional[Callable[[np.ndarray], float]] = None,
) -> OptimizeResult:
r"""Stochastic gradient descent with Robbins-Monro steps :math:`a_k = a_0/(k+1)`.
Parameters
----------
noisy_grad : callable
``noisy_grad(x, rng) -> ndarray``: an unbiased, noisy observation
of :math:`\nabla f(x)` (or, more generally, of the function whose
root is sought), drawing its randomness from ``rng``.
x0 : array_like
Starting point.
a0 : float
Step-size scale; the step at iteration ``k`` (from 0) is ``a0 / (k + 1)``.
n_iter : int
Number of iterations (there is no reliable stopping test on noisy data).
seed : int, optional
Seed for the :class:`numpy.random.Generator` passed to ``noisy_grad``.
f : callable, optional
The (noise-free) objective, used only to report ``fun``; ``nan`` if omitted.
Returns
-------
OptimizeResult
Notes
-----
For :math:`f(x) = \tfrac12\|x - \mu\|^2` and ``a0 = 1`` the iterate
after ``n`` steps is exactly the sample mean of the ``n`` noisy
observations of :math:`\mu` -- the running average is the simplest
stochastic-approximation scheme.
Examples
--------
>>> import numpy as np
>>> noisy = lambda x, rng: x - 3.0 + rng.normal(size=x.shape)
>>> result = robbins_monro(noisy, [0.0], n_iter=20000, seed=0)
>>> bool(abs(result.x[0] - 3.0) < 0.05)
True
"""
rng = np.random.default_rng(seed)
x = np.asarray(x0, dtype=np.float64).copy()
path = [x.copy()]
for k in range(int(n_iter)):
x = x - (a0 / (k + 1)) * np.asarray(noisy_grad(x, rng), dtype=np.float64)
path.append(x.copy())
fun = float(f(x)) if f is not None else float("nan")
return OptimizeResult(x=x, fun=fun, path=np.array(path), iterations=int(n_iter), converged=True, method="robbins_monro")