Source code for pyproximal.optimization.cls_primal

__all__ = [
    "ProximalPoint",
    "ProximalGradient",
    "AndersonProximalGradient",
    "GeneralizedProximalGradient",
    "HQS",
    "ADMM",
    "ADMML2",
    "LinearizedADMM",
    "TwIST",
    "DouglasRachfordSplitting",
    "PPXA",
    "ConsensusADMM",
]

from collections.abc import Sequence
from math import sqrt
from typing import TYPE_CHECKING, Any, Optional, cast

import numpy as np
import pylops
from pylops.optimization.callback import Callbacks
from pylops.optimization.leastsquares import regularized_inversion
from pylops.utils.backend import get_array_module, to_numpy
from pylops.utils.typing import NDArray

from pyproximal.optimization.basesolver import Solver
from pyproximal.proximal import L2
from pyproximal.ProxOperator import ProxOperator
from pyproximal.utils.bilinear import BilinearOperator

if TYPE_CHECKING:
    from pylops.linearoperator import LinearOperator


# need to check pylops version since _callback_stop
# is only available in pylops>=2.6.0
sp_version = pylops.__version__.split(".")
if int(sp_version[0]) < 2 or (int(sp_version[0]) == 2 and int(sp_version[1]) < 6):

    def _callback_stop(callbacks: Sequence[Callbacks]) -> bool:
        return False
else:
    from pylops.optimization.callback import _callback_stop  # type: ignore[no-redef]


def _backtracking(
    x: NDArray,
    tau: float,
    proxf: ProxOperator,
    proxg: ProxOperator,
    epsg: float,
    beta: float = 0.5,
    niterback: int = 10,
) -> tuple[NDArray, float]:
    r"""Backtracking

    Line-search algorithm for finding step sizes in proximal algorithms when
    the Lipschitz constant of the operator is unknown (or expensive to
    estimate).

    """

    def ftilde(x: NDArray, y: NDArray, f: ProxOperator, tau: float) -> float:
        xy = x - y
        return float(
            f(y) + np.dot(f.grad(y), xy) + (1.0 / (2.0 * tau)) * np.linalg.norm(xy) ** 2
        )

    iiterback = 0
    while iiterback < niterback:
        z = proxg.prox(x - tau * proxf.grad(x), epsg * tau)
        ft = ftilde(z, x, proxf, tau)
        if proxf(z) <= ft:
            break
        tau *= beta
        iiterback += 1
    return z, tau


def _x0z0_init(
    x0: NDArray | None,
    z0: NDArray | None,
    Op: Optional["LinearOperator"] = None,
    z0name: str | None = "z0",
    Opname: str | None = "Op",
) -> tuple[NDArray, NDArray]:
    r"""Initialize x0 and z0

    Initialize x0 and z0 using the following convention.

    For ``Op=None``:
    - if both are provided, they are simply returned;
    - if only one is provided (the other is ``None``), the one provided
      is copied to the other one.

    For ``Op!=None``, ``x0`` must be provided, and:
    - if both are provided, they are simply returned;
    - if ``z0`` is not provided, set to ``Op @ x0``.

    Parameters
    ----------
    x0 : :obj:`numpy.ndarray`
        Initial vector
    z0 : :obj:`numpy.ndarray`
        Initial auxiliary vector
    Op : :obj:`pylops.LinearOperator`, optional
        Linear Operator to apply to ``x0``
    z0name : :obj:`str`, optional
        Name to display in error message instead of ``z0``
    Opname : :obj:`str`, optional
        Name to display in error message instead of ``Op``

    """
    if x0 is None and z0 is None:
        msg = f"Both x0 or {z0name} are None, provide either of them or both"
        raise ValueError(msg)

    if Op is None:
        if x0 is None:
            x0 = z0.copy()  # type: ignore[union-attr]
        elif z0 is None:
            z0 = x0.copy()
    else:
        if x0 is None:
            msg = f"x0 must be provided when {Opname} is also provided"
            raise ValueError(msg)
        elif z0 is None:
            z0 = Op @ x0
    return x0, z0


[docs] class ProximalPoint(Solver): r"""Proximal point algorithm Solves the following minimization problem using Proximal point algorithm: .. math:: \mathbf{x} = \argmin_\mathbf{x} f(\mathbf{x}) where :math:`f(\mathbf{x})` is any convex function that has a known proximal operator. Notes ----- The Proximal point algorithm can be expressed by the following recursion: .. math:: \mathbf{x}^{k+1} = \prox_{\tau f}(\mathbf{x}^k) """ pf: float def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=60) strpar = f"Proximal operator: {type(self.prox).__name__}" strpar1 = f"tau = {self.tau:6e}\ttol = {str(self.tol)}\tniter = {self.niter}" print(strpar) print(strpar1) print("-" * 60 + "\n") if not xcomplex: head1 = " Itn x[0] f" else: head1 = " Itn x[0] f" print(head1) def _print_step(self, x: NDArray) -> None: if self.tol is None: self.pf = self.prox(x) strx = f"{x[0]:1.2e} " if np.iscomplexobj(x) else f"{x[0]:11.4e} " msg = f"{self.iiter:6g} " + strx + f"{self.pf:11.4e}" print(msg) def setup( # type: ignore[override] self, prox: "ProxOperator", x0: NDArray, tau: float, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> NDArray: r"""Setup solver Parameters ---------- prox : :obj:`pyproximal."ProxOperator"` Proximal operator x0 : :obj:`numpy.ndarray` Initial guess tau : :obj:`float` Positive scalar weight niter : :obj:`int`, optional Number of iterations of iterative scheme (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display setup log Returns ------- x : :obj:`numpy.ndarray` Initial guess """ self.prox = prox self.tau = tau self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # create variables to track the objective function and iterations pf = self.prox(x0) self.pf, self.pfold = pf, pf self.cost: list[float] = [] self.cost.append(float(self.pf)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x0 def step(self, x: NDArray, show: bool = False) -> NDArray: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the proximal point algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector """ x = self.prox.prox(x, self.tau) # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfold = self.pf self.pf = self.prox(x) if self.pfold != 0 and self.ncp.abs(1.0 - self.pf / self.pfold) < self.tol: self.tolbreak = True self.iiter += 1 if show: self._print_step(x) if self.tol is not None or show: self.cost.append(float(self.pf)) return x def run( self, x: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> NDArray: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the proximal point algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x = self.step(x, showstep) self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x def solve( # type: ignore[override] self, prox: "ProxOperator", x0: NDArray, tau: float, niter: int = 10, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- prox : :obj:`pyproximal."ProxOperator"` Proximal operator x0 : :obj:`numpy.ndarray`, optional Initial guess tau : :obj:`float` Positive scalar weight niter : :obj:`int`, optional Number of iterations tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x = self.setup(prox=prox, x0=x0, tau=tau, niter=niter, tol=tol, show=show) x = self.run(x, niter, show=show, itershow=itershow) self.finalize(60, show) return x, self.iiter, self.cost
[docs] class ProximalGradient(Solver): r"""Proximal gradient (optionally accelerated) Solves the following minimization problem using (Accelerated) Proximal gradient algorithm: .. math:: \mathbf{x} = \argmin_\mathbf{x} f(\mathbf{x}) + \epsilon g(\mathbf{x}) where :math:`f(\mathbf{x})` is a smooth convex function with a uniquely defined gradient and :math:`g(\mathbf{x})` is any convex function that has a known proximal operator. Notes ----- The Proximal gradient algorithm can be expressed by the following recursion: .. math:: \mathbf{x}^{k+1} = \mathbf{y}^k + \eta (\prox_{\tau^k \epsilon g}(\mathbf{y}^k - \tau^k \nabla f(\mathbf{y}^k)) - \mathbf{y}^k) \\ \mathbf{y}^{k+1} = \mathbf{x}^k + \omega^k (\mathbf{x}^k - \mathbf{x}^{k-1}) where at each iteration :math:`\tau^k` can be estimated by back-tracking as follows: .. math:: \begin{aligned} &\tau = \tau^{k-1} &\\ &repeat \; \mathbf{z} = \prox_{\tau \epsilon g}(\mathbf{x}^k - \tau \nabla f(\mathbf{x}^k)), \tau = \beta \tau \quad if \; f(\mathbf{z}) \leq \tilde{f}_\tau(\mathbf{z}, \mathbf{x}^k) \\ &\tau^k = \tau, \quad \mathbf{x}^{k+1} = \mathbf{z} &\\ \end{aligned} where :math:`\tilde{f}_\tau(\mathbf{x}, \mathbf{y}) = f(\mathbf{y}) + \nabla f(\mathbf{y})^T (\mathbf{x} - \mathbf{y}) + 1/(2\tau)||\mathbf{x} - \mathbf{y}||_2^2`. Different accelerations are provided: - ``acceleration=None``: :math:`\omega^k = 0`; - ``acceleration=vandenberghe`` [1]_: :math:`\omega^k = k / (k + 3)` for ` - ``acceleration=fista``: :math:`\omega^k = (t_{k-1}-1)/t_k` where :math:`t_k = (1 + \sqrt{1+4t_{k-1}^{2}}) / 2` [2]_ .. [1] Vandenberghe, L., "Fast proximal gradient methods", 2010. .. [2] Beck, A., and Teboulle, M. "A Fast Iterative Shrinkage-Thresholding Algorithm for Linear Inverse Problems", SIAM Journal on Imaging Sciences, vol. 2, pp. 183-202. 2009. """ def _print_setup(self, epsg_print: str, xcomplex: bool = False) -> None: self._print_solver(nbar=81) tau_str = ( f"{self.tau[0]:4.2e}" if self.tau.size == 1 else ", ".join(f"{tau:4.2e}" for tau in self.tau) ) strpar = ( f"Proximal operator (f): {type(self.proxf).__name__}\n" f"Proximal operator (g): {type(self.proxg).__name__}\n" ) strpar1 = f"tau = {tau_str}\t\tbacktrack = {self.backtracking}" strpar2 = f"beta = {self.beta}\t\tepsg = {epsg_print}\t\tacceleration = {self.acceleration}" strpar3 = f"niter = {self.niter}\t\tniterback = {self.niterback}\t\ttol = {str(self.tol)}" print(strpar) print(strpar1) print(strpar2) print(strpar3) print("-" * 81 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+eps*g tau" else: head1 = " Itn x[0] f g J=f+eps*g tau" print(head1) def _print_step( self, x: NDArray, pf: float | None, pg: float | None, epsg_prev: int ) -> None: if self.tol is None: pf, pg = self.proxf(x), self.proxg(x) self.pfg = pf + np.sum(epsg_prev * pg) x0 = to_numpy(x[0]) if x.ndim == 1 else to_numpy(x[0, 0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " tau_str = ( f"{self.tau[0]:4.2e}" if self.tau.size == 1 else ", ".join(f"{tau:4.2e}" for tau in self.tau) ) msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e} {tau_str}" ) print(msg) def setup( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, epsg: float | NDArray = 1.0, tau: float | None = None, backtracking: bool = False, beta: float = 0.5, eta: float = 1.0, acceleration: str | None = None, niterback: int = 100, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function (must have ``grad`` implemented) proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector epsg : :obj:`float` or :obj:`numpy.ndarray`, optional Scaling factor of g function. Can be a scalar for iteration-independent scaling or a 1d vector for iteration-dependent scaling tau : :obj:`float` or :obj:`numpy.ndarray`, optional Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. When ``tau=None``, backtracking is used to adaptively estimate the best tau at each iteration. Finally, note that :math:`\tau` can be chosen to be a vector when dealing with problems with multiple right-hand-sides backtracking : :obj:`bool`, optional Force backtracking, even if ``tau`` is not equal to ``None``. In this case the chosen ``tau`` will be used as the initial guess in the first step of backtracking beta : :obj:`float`, optional Backtracking parameter (must be between 0 and 1) eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 1, 0 excluded). acceleration : :obj:`str`, optional Acceleration (``None``, ``vandenberghe`` or ``fista``) niterback : :obj:`int`, optional Max number of iterations of backtracking niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess y : :obj:`numpy.ndarray` Initial guess for the auxiliary variable """ self.proxf = proxf self.proxg = proxg self.backtracking = backtracking self.beta = beta self.eta = eta self.niterback = niterback self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # check if epsg is a vector self.epsg = self.ncp.asarray(epsg, dtype=np.float32) if self.epsg.size == 1: if niter is None: epsg_print = str(self.epsg) else: self.epsg = epsg * self.ncp.ones(niter, dtype=np.float32) epsg_print = str(self.epsg[0]) else: epsg_print = "Multi" # set tau if tau is None: self.backtracking = True tau = 1.0 self.tau = self.ncp.atleast_1d(self.ncp.asarray(tau, dtype=np.float32)) # check acceleration if acceleration in [None, "None", "vandenberghe", "fista"]: self.acceleration = acceleration else: msg = "Acceleration should be None, vandenberghe or fista" raise NotImplementedError(msg) # initialize solver x = x0.copy() y = x.copy() # for accelaration self.t = 1.0 # create variables to track the objective function and iterations epsg_ = self.epsg if niter is None else self.epsg[0] pf, pg = self.proxf(x), self.proxg(x) pfg = pf + np.sum(epsg_ * pg) self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(epsg_print, np.iscomplexobj(x0)) return x, y def step( self, x: NDArray, y: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the proximal gradient algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the proximal gradient algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector y : :obj:`numpy.ndarray` Updated additional model vector """ xold = x.copy() # define epsg for current iteration if self.epsg.ndim == 0: epsg = self.epsg epsg_prev = self.epsg else: epsg = self.epsg[self.iiter] epsg_prev = self.epsg[self.iiter - 1] # proximal step if not self.backtracking: if self.eta == 1.0: x = self.proxg.prox(y - self.tau * self.proxf.grad(y), epsg * self.tau) else: x = x + self.eta * ( self.proxg.prox( x - self.tau * self.proxf.grad(x), epsg * self.tau, ) - x ) else: x, self.tau = _backtracking( y, cast(float, self.tau), self.proxf, self.proxg, epsg, beta=self.beta, niterback=self.niterback, ) if self.eta != 1.0: x = x + self.eta * ( self.proxg.prox( x - self.tau * self.proxf.grad(x), epsg * self.tau, ) - x ) # update internal parameters for bilinear operator if isinstance(self.proxf, BilinearOperator): self.proxf.updatexy(x) # update y if self.acceleration == "vandenberghe": omega = self.iiter / (self.iiter + 3) elif self.acceleration == "fista": told = self.t self.t = (1.0 + np.sqrt(1.0 + 4.0 * self.t**2)) / 2.0 omega = (told - 1.0) / self.t else: omega = 0 y = x + omega * (x - xold) # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf, pg = self.proxf(x), self.proxg(x) self.pfg = pf + np.sum(epsg * pg) if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, pf, pg, epsg_prev) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, y def run( self, x: NDArray, y: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the proximal gradient algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the proximal gradient algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, y = self.step(x, y, showstep) self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, y def solve( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, epsg: float | NDArray = 1.0, tau: float | None = None, backtracking: bool = False, beta: float = 0.5, eta: float = 1.0, acceleration: str | None = None, niterback: int = 100, niter: int = 10, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function (must have ``grad`` implemented) proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector epsg : :obj:`float` or :obj:`numpy.ndarray`, optional Scaling factor of g function. Can be a scalar for iteration-independent scaling or a 1d vector for iteration-dependent scaling tau : :obj:`float` or :obj:`numpy.ndarray`, optional Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. When ``tau=None``, backtracking is used to adaptively estimate the best tau at each iteration. Finally, note that :math:`\tau` can be chosen to be a vector when dealing with problems with multiple right-hand-sides backtracking : :obj:`bool`, optional Force backtracking, even if ``tau`` is not equal to ``None``. In this case the chosen ``tau`` will be used as the initial guess in the first step of backtracking beta : :obj:`float`, optional Backtracking parameter (must be between 0 and 1) eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 1, 0 excluded). acceleration : :obj:`str`, optional Acceleration (``None``, ``vandenberghe`` or ``fista``) niterback : :obj:`int`, optional Max number of iterations of backtracking niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, y = self.setup( proxf=proxf, proxg=proxg, x0=x0, epsg=epsg, tau=tau, backtracking=backtracking, beta=beta, eta=eta, acceleration=acceleration, niterback=niterback, niter=niter, tol=tol, show=show, ) x, y = self.run(x, y, niter, show=show, itershow=itershow) self.finalize(81, show) return x, y, self.iiter, self.cost
[docs] class AndersonProximalGradient(Solver): r"""Proximal gradient with Anderson acceleration Solves the following minimization problem using the Proximal gradient algorithm with Anderson acceleration: .. math:: \mathbf{x} = \argmin_\mathbf{x} f(\mathbf{x}) + \epsilon g(\mathbf{x}) where :math:`f(\mathbf{x})` is a smooth convex function with a uniquely defined gradient and :math:`g(\mathbf{x})` is any convex function that has a known proximal operator. Notes ----- The Proximal gradient algorithm with Anderson acceleration can be expressed by the following recursion [1]_: .. math:: m_k = min(m, k)\\ \mathbf{g}^{k} = \mathbf{x}^{k} - \tau^k \nabla f(\mathbf{x}^k)\\ \mathbf{r}^{k} = \mathbf{g}^{k} - \mathbf{g}^{k}\\ \mathbf{G}^{k} = [\mathbf{g}^{k},..., \mathbf{g}^{k-m_k}]\\ \mathbf{R}^{k} = [\mathbf{r}^{k},..., \mathbf{r}^{k-m_k}]\\ \alpha_k = (\mathbf{R}^{kT} \mathbf{R}^{k})^{-1} \mathbf{1} / \mathbf{1}^T (\mathbf{R}^{kT} \mathbf{R}^{k})^{-1} \mathbf{1}\\ \mathbf{y}^{k+1} = \mathbf{G}^{k} \alpha_k\\ \mathbf{x}^{k+1} = \prox_{\tau^{k+1} g}(\mathbf{y}^{k+1}) where :math:`m` equals ``nhistory``, :math:`k=1,2,...,n_{iter}`, :math:`\mathbf{y}^{0}=\mathbf{x}^{0}`, :math:`\mathbf{y}^{1}=\mathbf{x}^{0} - \tau^0 \nabla f(\mathbf{x}^0)`, :math:`\mathbf{x}^{1}=\prox_{\tau^k g}(\mathbf{y}^{1})`, and :math:`\mathbf{g}^{0}=\mathbf{y}^{1}`. Refer to [1]_ for the guarded version of the algorithm (when ``safeguard=True``). .. [1] Mai, V., and Johansson, M. "Anderson Acceleration of Proximal Gradient Methods", 2020. """ def _print_setup(self, epsg_print: str, xcomplex: bool = False) -> None: self._print_solver(nbar=81) tau_str = ( f"{self.tau[0]:4.2e}" if self.tau.size == 1 else ", ".join(f"{tau:4.2e}" for tau in self.tau) ) strpar = ( f"Proximal operator (f): {type(self.proxf).__name__}\n" f"Proximal operator (g): {type(self.proxg).__name__}\n" ) strpar1 = f"tau = {tau_str}\t\tepsg = {epsg_print}\t\tniter = {self.niter}" strpar2 = f"nhist = {self.nhistory}\t\tepsr = {self.epsr:4.2e}" strpar3 = f"guard = {str(self.safeguard)}\t\ttol = {str(self.tol)}" print(strpar) print(strpar1) print(strpar2) print(strpar3) print("-" * 81 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+eps*g tau" else: head1 = " Itn x[0] f g J=f+eps*g tau" print(head1) def _print_step(self, x: NDArray, pg: float | None, epsg_prev: int) -> None: if self.tol is None: self.pf, pg = self.proxf(x), self.proxg(x) self.pfg = self.pf + np.sum(epsg_prev * pg) x0 = to_numpy(x[0]) if x.ndim == 1 else to_numpy(x[0, 0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " tau_str = ( f"{self.tau[0]:4.2e}" if self.tau.size == 1 else ", ".join(f"{tau:4.2e}" for tau in self.tau) ) msg = ( f"{self.iiter:6g} " + strx + f"{self.pf:10.3e} {pg:10.3e} {self.pfg:10.3e} {tau_str}" ) print(msg) def setup( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, epsg: float | NDArray = 1.0, tau: float | NDArray = 1.0, epsr: float = 1e-10, safeguard: bool = False, nhistory: int = 10, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function (must have ``grad`` implemented) proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector epsg : :obj:`float` or :obj:`numpy.ndarray`, optional Scaling factor of g function. Can be a scalar for iteration-independent scaling or a 1d vector for iteration-dependent scaling tau : :obj:`float` or :obj:`numpy.ndarray`, optional Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. Note that :math:`\tau` can be chosen to be a vector when dealing with problems with multiple right-hand-sides epsr : :obj:`float`, optional Scaling factor for regularization added to the inverse of :math:\mathbf{R}^T \mathbf{R}` safeguard : :obj:`bool`, optional Apply safeguarding strategy to the update (``True``) or not (``False``) nhistory : :obj:`int`, optional Number of previous iterates to be kept in memory (to compute the scaling factors) niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess """ self.proxf = proxf self.proxg = proxg self.epsr = epsr self.safeguard = safeguard self.nhistory = nhistory self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # check if epsg is a vector self.epsg = self.ncp.asarray(epsg, dtype=np.float32) if self.epsg.size == 1: if niter is None: epsg_print = str(self.epsg) else: self.epsg = self.epsg * self.ncp.ones(niter, dtype=np.float32) epsg_print = str(self.epsg[0]) else: epsg_print = "Multi" # set tau self.tau = self.ncp.atleast_1d(self.ncp.asarray(tau, dtype=np.float32)) # initialize solver epsg_ = self.epsg if niter is None else self.epsg[0] y = x0 - self.tau * proxf.grad(x0) x = self.proxg.prox(y, epsg_ * self.tau) # set history of iterates for Anderson acceleration g = y.copy() r = g - x0 self.R, self.G = ( [ g, ], [ r, ], ) # create variables to track the objective function and iterations self.pf = self.proxf(x) pg = self.proxg(x) pfg = self.pf + np.sum(epsg_ * pg) self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(epsg_print, np.iscomplexobj(x0)) return x, y def step( self, x: NDArray, y: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the Andersonproximal gradient algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the Anderson proximal gradient algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector y : :obj:`numpy.ndarray` Updated additional model vector """ # define epsg for current iteration if self.epsg.ndim == 0: epsg = self.epsg epsg_prev = self.epsg else: epsg = self.epsg[self.iiter] epsg_prev = self.epsg[self.iiter - 1] # update fix point g = x - self.tau * self.proxf.grad(x) r = g - y # update history vectors self.R.insert(0, r) self.G.insert(0, g) if self.iiter >= self.nhistory - 1: self.R.pop(-1) self.G.pop(-1) # solve for alpha coefficients Rstack = np.vstack(self.R) Rinv = np.linalg.pinv( Rstack @ Rstack.T + self.epsr * np.linalg.norm(Rstack) ** 2 ) ones = np.ones(min(self.nhistory, self.iiter + 2)) Rinvones = Rinv @ ones alpha = Rinvones / (ones[None] @ Rinvones) if not self.safeguard: # update auxiliary variable y = np.vstack(self.G).T @ alpha # update main variable x = self.proxg.prox(y, epsg * self.tau) else: # update auxiliary variable ytest = np.vstack(self.G).T @ alpha # update main variable xtest = self.proxg.prox(ytest, epsg * self.tau) # check if function is decreased, otherwise do basic PG step pfold, self.pf = self.pf, self.proxf(xtest) if ( self.pf <= pfold - self.tau * np.linalg.norm(self.proxf.grad(x)) ** 2 / 2 ): y = ytest x = xtest else: x = self.proxg.prox(g, epsg * self.tau) y = g # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg self.pf, pg = self.proxf(x), self.proxg(x) self.pfg = self.pf + np.sum(epsg * pg) if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pg = 0.0 self.iiter += 1 if show: self._print_step(x, pg, epsg_prev) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, y def run( self, x: NDArray, y: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> NDArray: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the Anderson proximal gradient algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the Anderson proximal gradient algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, y = self.step(x, y, showstep) self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, y def solve( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, epsg: float | NDArray = 1.0, tau: float | NDArray = 1.0, epsr: float = 1e-10, safeguard: bool = False, nhistory: int = 10, niter: int = 10, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function (must have ``grad`` implemented) proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector epsg : :obj:`float` or :obj:`numpy.ndarray`, optional Scaling factor of g function. Can be a scalar for iteration-independent scaling or a 1d vector for iteration-dependent scaling tau : :obj:`float` or :obj:`numpy.ndarray`, optional Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. Note that :math:`\tau` can be chosen to be a vector when dealing with problems with multiple right-hand-sides epsr : :obj:`float`, optional Scaling factor for regularization added to the inverse of :math:\mathbf{R}^T \mathbf{R}` safeguard : :obj:`bool`, optional Apply safeguarding strategy to the update (``True``) or not (``False``) nhistory : :obj:`int`, optional Number of previous iterates to be kept in memory (to compute the scaling factors) niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, y = self.setup( proxf=proxf, proxg=proxg, x0=x0, epsg=epsg, tau=tau, epsr=epsr, safeguard=safeguard, nhistory=nhistory, niter=niter, tol=tol, show=show, ) x, y = self.run(x, y, niter, show=show, itershow=itershow) self.finalize(81, show) return x, y, self.iiter, self.cost
[docs] class GeneralizedProximalGradient(Solver): r"""Generalized Proximal gradient Solves the following minimization problem using Generalized Proximal gradient algorithm: .. math:: \mathbf{x} = \argmin_\mathbf{x} \sum_{i=1}^n f_i(\mathbf{x}) + \sum_{j=1}^m \epsilon_j g_j(\mathbf{x}),~~n,m \in \mathbb{N}^+ where the :math:`f_i(\mathbf{x})` are smooth convex functions with a uniquely defined gradient and the :math:`g_j(\mathbf{x})` are any convex function that have a known proximal operator. Notes ----- The Generalized Proximal gradient algorithm can be expressed by the following recursion [1]_: .. math:: \text{for } j=1,\cdots,n, \\ ~~~~\mathbf z_j^{k+1} = \mathbf z_j^{k} + \eta \left[prox_{\frac{\tau^k \epsilon_j}{w_j} g_j}\left(2 \mathbf{x}^{k} - \mathbf{z}_j^{k} - \tau^k \sum_{i=1}^n \nabla f_i(\mathbf{x}^{k})\right) - \mathbf{x}^{k} \right] \\ \mathbf{x}^{k+1} = \sum_{j=1}^n w_j \mathbf z_j^{k+1} \\ where :math:`\sum_{j=1}^n w_j=1`. In the current implementation, :math:`w_j=1/n` when not provided. .. [1] Raguet, H., Fadili, J. and PeyrΓ©, G. "Generalized Forward-Backward Splitting", arXiv, 2012. """ def _print_setup(self, epsg_print: str, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = ( f"Proximal operators (f): {[type(proxf).__name__ for proxf in self.proxfs]}\n" f"Proximal operators (g): {[type(proxg).__name__ for proxg in self.proxgs]}\n" ) strpar1 = ( f"tau = {float(self.tau):4.2e}\tepsg = {epsg_print}\tniter = {self.niter}" ) print(strpar) print(strpar1) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+g" else: head1 = ( " Itn x[0] f g J=f+g" ) print(head1) def _print_step(self, x: NDArray, pf: float | None, pg: float | None) -> None: if self.tol is None: pf = np.sum([proxf(x) for proxf in self.proxfs]) pg = np.sum( [ eg * proxg(x) for proxg, eg in zip(self.proxgs, self.epsg, strict=True) ] ) self.pfg = pf + pg x0 = to_numpy(x[0]) if x.ndim == 1 else to_numpy(x[0, 0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e}" ) print(msg) def setup( # type: ignore[override] self, proxfs: list[ProxOperator], proxgs: list[ProxOperator], x0: NDArray, tau: float, epsg: float | NDArray = 1.0, weights: NDArray | None = None, eta: float = 1.0, acceleration: str | None = None, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxfs : :obj:`list` Proximal operators of the :math:`f_i` functions (must have ``grad`` implemented) proxgs : :obj:`list` Proximal operators of the :math:`g_j` functions x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\sum_{i=1}^n \nabla f_i`. epsg : :obj:`float` or :obj:`numpy.ndarray`, optional Scaling factor(s) of ``g`` function(s). If a scalar is provided the same scaling factor is applied to every ``g`` function. weights : :obj:`float`, optional Weighting factors of ``g`` functions. Must sum to 1. eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 1, 0 excluded). Note that this will be only used when ``acceleration=None``. acceleration: :obj:`str`, optional Acceleration (``None``, ``vandenberghe`` or ``fista``) niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess y : :obj:`numpy.ndarray` Initial guess for the auxiliary variable """ self.proxfs = proxfs self.proxgs = proxgs self.tau = tau self.eta = eta self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # check if weights sum to 1 self.weights = ( np.ones(len(proxgs)) / len(proxgs) if weights is None else weights ) if len(self.weights) != len(self.proxgs) or np.sum(self.weights) != 1.0: msg = f"weights={self.weights} must be an array of size {len(self.proxgs)} summing to 1" raise ValueError(msg) # check if epsg is a vector and set same value for all gs otherwise self.epsg = self.ncp.asarray(epsg, dtype=np.float32) if self.epsg.size == 1: self.epsg = epsg * self.ncp.ones(len(proxgs), dtype=np.float32) epsg_print = str(self.epsg[0]) else: if self.epsg.size != len(proxgs): msg = ( "epgs must be a scalar or a vector of size " "equal to the number of g functions, " f"provided {self.epsg.size} instead" ) raise ValueError(msg) epsg_print = "Multi" # check acceleration if acceleration in [None, "None", "vandenberghe", "fista"]: self.acceleration = acceleration else: msg = "Acceleration should be None, vandenberghe or fista" raise NotImplementedError(msg) # initialize solver x = x0.copy() y = x.copy() self.zs = [x.copy() for _ in range(len(proxgs))] # for accelaration self.t = 1.0 # create variables to track the objective function and iterations pf = np.sum([proxf(x) for proxf in self.proxfs]) pg = np.sum( [eg * proxg(x) for proxg, eg in zip(self.proxgs, self.epsg, strict=True)] ) pfg = pf + pg self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(epsg_print, np.iscomplexobj(x0)) return x, y def step( self, x: NDArray, y: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the generalized proximal gradient algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the generalized proximal gradient algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector y : :obj:`numpy.ndarray` Updated additional model vector """ xold = x.copy() # gradient grad = np.zeros_like(x) for _, proxf in enumerate(self.proxfs): grad += proxf.grad(x) # proximal step x = np.zeros_like(x) for i, proxg in enumerate(self.proxgs): ztmp = 2 * y - self.zs[i] - self.tau * grad ztmp = proxg.prox(ztmp, self.tau * self.epsg[i] / self.weights[i]) self.zs[i] += self.eta * (ztmp - y) x += self.weights[i] * self.zs[i] # update y if self.acceleration == "vandenberghe": omega = self.iiter / (self.iiter + 3) elif self.acceleration == "fista": told = self.t self.t = (1.0 + np.sqrt(1.0 + 4.0 * self.t**2)) / 2.0 omega = (told - 1.0) / self.t else: omega = 0 y = x + omega * (x - xold) # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf = np.sum([proxf(x) for proxf in self.proxfs]) pg = np.sum( [ eg * proxg(x) for proxg, eg in zip(self.proxgs, self.epsg, strict=True) ] ) self.pfg = pf + pg if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, pf, pg) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, y def run( self, x: NDArray, y: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the generalized proximal gradient algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the generalized proximal gradient algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, y = self.step(x, y, showstep) self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, y def solve( # type: ignore[override] self, proxfs: list[ProxOperator], proxgs: list[ProxOperator], x0: NDArray, tau: float, epsg: float | NDArray = 1.0, weights: NDArray | None = None, eta: float = 1.0, acceleration: str | None = None, niter: int = 10, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxfs : :obj:`list` Proximal operators of the :math:`f_i` functions (must have ``grad`` implemented) proxgs : :obj:`list` Proximal operators of the :math:`g_j` functions x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\sum_{i=1}^n \nabla f_i`. epsg : :obj:`float` or :obj:`numpy.ndarray`, optional Scaling factor(s) of ``g`` function(s). If a scalar is provided the same scaling factor is applied to every ``g`` function. weights : :obj:`float`, optional Weighting factors of ``g`` functions. Must sum to 1. eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 1, 0 excluded). Note that this will be only used when ``acceleration=None``. acceleration: :obj:`str`, optional Acceleration (``None``, ``vandenberghe`` or ``fista``) niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, y = self.setup( proxfs=proxfs, proxgs=proxgs, x0=x0, tau=tau, epsg=epsg, weights=weights, eta=eta, acceleration=acceleration, niter=niter, tol=tol, show=show, ) x, y = self.run(x, y, niter, show=show, itershow=itershow) self.finalize(65, show) return x, y, self.iiter, self.cost
[docs] class HQS(Solver): r"""Half Quadratic splitting Solves the following minimization problem using Half Quadratic splitting algorithm: .. math:: \mathbf{x},\mathbf{z} = \argmin_{\mathbf{x},\mathbf{z}} f(\mathbf{x}) + g(\mathbf{z}) \\ s.t. \; \mathbf{x}=\mathbf{z} where :math:`f(\mathbf{x})` and :math:`g(\mathbf{z})` are any convex function that has a known proximal operator. Notes ----- The HQS algorithm can be expressed by the following recursion [1]_: .. math:: \mathbf{z}^{k+1} = \prox_{\tau g}(\mathbf{x}^{k}) \\ \mathbf{x}^{k+1} = \prox_{\tau f}(\mathbf{z}^{k+1}) for ``gfirst=False``, or .. math:: \mathbf{x}^{k+1} = \prox_{\tau f}(\mathbf{z}^{k}) \\ \mathbf{z}^{k+1} = \prox_{\tau g}(\mathbf{x}^{k+1}) for ``gfirst=False``. Note that ``x`` and ``z`` converge to each other, however if iterations are stopped too early ``x`` is guaranteed to belong to the domain of ``f`` while ``z`` is guaranteed to belong to the domain of ``g``. Depending on the problem either of the two may be the best solution. .. [1] D., Geman, and C., Yang, "Nonlinear image recovery with halfquadratic regularization", IEEE Transactions on Image Processing, 4, 7, pp. 932-946, 1995. """ def _print_setup(self, tau_print: str, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = ( f"Proximal operator (f): {type(self.proxf).__name__}\n" f"Proximal operator (g): {type(self.proxg).__name__}\n" ) strpar1 = f"tau = {tau_print}\tniter = {self.niter}" strpar2 = f"gfirst = {self.gfirst}\ttol = {self.tol}" print(strpar) print(strpar1) print(strpar2) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+g" else: head1 = ( " Itn x[0] f g J=f+g" ) print(head1) def _print_step(self, x: NDArray, pf: float | None, pg: float | None) -> None: if self.tol is None: pf, pg = self.proxf(x), self.proxg(x) self.pfg = pf + pg x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e}" ) print(msg) def setup( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, tau: float | NDArray, z0: NDArray | None = None, gfirst: bool = True, niter: int | None = None, tol: float | None = None, callbackz: bool = False, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector (not required when ``gfirst=False``, can pass ``None``) tau : :obj:`float` or :obj:`numpy.ndarray` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. Finally note that :math:`\tau` can be chosen to be a vector of size ``niter`` such that different :math:`\tau` is used at different iterations (i.e., continuation strategy) z0 : :obj:`numpy.ndarray`, optional Initial z vector (not required when ``gfirst=True``) gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbackz : :obj:`bool`, optional Modify callback signature to (``callback(x, z)``) when ``callbackz=True`` show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess z : :obj:`numpy.ndarray` Initial guess for the auxiliary variable Raises ------ ValueError If both ``x0`` and ``z0`` are set to ``None`` """ self.proxf = proxf self.proxg = proxg self.gfirst = gfirst self.niter = niter self.tol = tol self.callbackz = callbackz self.ncp = get_array_module(x0) # check if tau is a vector self.tau = self.ncp.asarray(tau, dtype=np.float32) if self.tau.size == 1: if niter is None: tau_print = str(np.round(self.tau, 6)) else: self.tau = self.tau * self.ncp.ones(niter, dtype=np.float32) tau_print = str(np.round(self.tau[0], 6)) else: tau_print = "Variable" # initialize solver x, z = _x0z0_init(x0, z0) # for accelaration self.t = 1.0 # create variables to track the objective function and iterations pf, pg = self.proxf(x), self.proxg(x) pfg = pf + pg self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(tau_print, np.iscomplexobj(x0)) return x, z def step( self, x: NDArray, z: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the HQS algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the HQS algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector z : :obj:`numpy.ndarray` Updated additional model vector """ # define tau for current iteration if self.tau.ndim == 0: tau = self.tau else: tau = self.tau[self.iiter] # proximal steps if self.gfirst: z = self.proxg.prox(x, tau) x = self.proxf.prox(z, tau) else: x = self.proxf.prox(z, tau) z = self.proxg.prox(x, tau) # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf = self.proxf(x) pg = self.proxg(x) self.pfg = pf + pg if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, pf, pg) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, z def run( self, x: NDArray, z: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the HQS algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the HQS algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, z = self.step(x, z, showstep) if self.callbackz: self.callback(x, z) else: self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, z def solve( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, tau: float | NDArray, z0: NDArray | None = None, gfirst: bool = True, niter: int | None = None, tol: float | None = None, callbackz: bool = False, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector (not required when ``gfirst=False``, can pass ``None``) tau : :obj:`float` or :obj:`numpy.ndarray` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. Finally note that :math:`\tau` can be chosen to be a vector of size ``niter`` such that different :math:`\tau` is used at different iterations (i.e., continuation strategy) z0 : :obj:`numpy.ndarray`, optional Initial z vector (not required when ``gfirst=True``) gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbackz : :obj:`bool`, optional Modify callback signature to (``callback(x, z)``) when ``callbackz=True`` show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, z = self.setup( proxf=proxf, proxg=proxg, x0=x0, tau=tau, z0=z0, gfirst=gfirst, niter=niter, tol=tol, callbackz=callbackz, show=show, ) x, z = self.run(x, z, niter, show=show, itershow=itershow) self.finalize(65, show) return x, z, self.iiter, self.cost
[docs] class ADMM(Solver): r"""Alternating Direction Method of Multipliers Solves the following minimization problem using Alternating Direction Method of Multipliers: .. math:: \mathbf{x},\mathbf{z} = \argmin_{\mathbf{x},\mathbf{z}} f(\mathbf{x}) + g(\mathbf{z}) \\ s.t. \; \mathbf{x}=\mathbf{z} where :math:`f(\mathbf{x})` and :math:`g(\mathbf{z})` are any convex function that has a known proximal operator. ADMM can also solve the problem of the form above with a more general constraint: :math:`\mathbf{Ax}+\mathbf{Bz}=\mathbf{c}`. This routine implements the special case where :math:`\mathbf{A}=\mathbf{I}`, :math:`\mathbf{B}=-\mathbf{I}`, and :math:`\mathbf{c}=\mathbf{0}`, as a general algorithm can be obtained for any choice of :math:`f` and :math:`g` provided they have a known proximal operator. On the other hand, for more general choice of :math:`\mathbf{A}`, :math:`\mathbf{B}`, and :math:`\mathbf{c}`, the iterations are not generalizable, i.e. they depend on the choice of the :math:`f` and :math:`g` functions. For this reason, we currently only provide an additional solver for the special case where :math:`f` is a :class:`pyproximal.proximal.L2` operator with a linear operator :math:`\mathbf{G}` and data :math:`\mathbf{y}`, :math:`\mathbf{B}=-\mathbf{I}` and :math:`\mathbf{c}=\mathbf{0}`, called :func:`pyproximal.optimization.primal.ADMML2`. Note that for the very same choice of :math:`\mathbf{B}` and :math:`\mathbf{c}`, the :func:`pyproximal.optimization.primal.LinearizedADMM` can also be used (and this does not require a specific choice of :math:`f`). See Also -------- ADMML2: ADMM with L2 misfit function LinearizedADMM: Linearized ADMM Notes ----- The ADMM algorithm can be expressed by the following recursion [1]_: .. math:: \mathbf{x}^{k+1} = \prox_{\tau f}(\mathbf{z}^{k} - \mathbf{u}^{k})\\ \mathbf{z}^{k+1} = \prox_{\tau g}(\mathbf{x}^{k+1} + \mathbf{u}^{k})\\ \mathbf{u}^{k+1} = \mathbf{u}^{k} + \mathbf{x}^{k+1} - \mathbf{z}^{k+1} Note that ``x`` and ``z`` converge to each other, however if iterations are stopped too early ``x`` is guaranteed to belong to the domain of ``f`` while ``z`` is guaranteed to belong to the domain of ``g``. Depending on the problem either of the two may be the best solution. .. [1] S. Boyd, N. Parikh, E. Chu, B. Peleato, and J. Eckstein. 2011. Distributed optimization and statistical learning via the alternating direction method of multipliers. Foundations and Trends in Machine Learning, 3 (1), 1-122. https://doi.org/10.1561/2200000016. """ def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = ( f"Proximal operator (f): {type(self.proxf).__name__}\n" f"Proximal operator (g): {type(self.proxg).__name__}\n" ) strpar1 = f"tau = {float(self.tau):6e}\tniter = {self.niter}" strpar2 = f"gfirst = {self.gfirst}\t\ttol = {self.tol}" print(strpar) print(strpar1) print(strpar2) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+g" else: head1 = ( " Itn x[0] f g J=f+g" ) print(head1) def _print_step(self, x: NDArray, pf: float | None, pg: float | None) -> None: if self.tol is None: pf, pg = self.proxf(x), self.proxg(x) self.pfg = pf + pg x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e}" ) print(msg) def setup( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, tau: float, z0: NDArray | None = None, gfirst: bool = False, niter: int | None = None, tol: float | None = None, callbackz: bool = False, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector (not required when ``gfirst=False``, can pass ``None``) tau : :obj:`float` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. z0 : :obj:`numpy.ndarray`, optional Initial z vector (not required when ``gfirst=True``) gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbackz : :obj:`bool`, optional Modify callback signature to (``callback(x, z)``) when ``callbackz=True`` show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess z : :obj:`numpy.ndarray` Initial guess for the auxiliary variable Raises ------ ValueError If both ``x0`` and ``z0`` are set to ``None`` """ self.proxf = proxf self.proxg = proxg self.tau = tau self.niter = niter self.gfirst = gfirst self.tol = tol self.callbackz = callbackz self.ncp = get_array_module(x0) # initialize solver x, z = _x0z0_init(x0, z0) self.u = self.ncp.zeros_like(x) # create variables to track the objective function and iterations pf = self.proxf(x) pg = self.proxg(x) pfg = pf + pg self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x, z def step( self, x: NDArray, z: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the ADDM algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the ADDM algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector z : :obj:`numpy.ndarray` Updated additional model vector """ # proximal steps if self.gfirst: z = self.proxg.prox(x + self.u, self.tau) x = self.proxf.prox(z - self.u, self.tau) else: x = self.proxf.prox(z - self.u, self.tau) z = self.proxg.prox(x + self.u, self.tau) self.u = self.u + x - z # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf = self.proxf(x) pg = self.proxg(x) self.pfg = pf + pg if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, pf, pg) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, z def run( self, x: NDArray, z: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the ADDM algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the ADDM algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, z = self.step(x, z, showstep) if self.callbackz: self.callback(x, z) else: self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, z def solve( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, tau: float, z0: NDArray | None = None, gfirst: bool = False, niter: int = 10, tol: float | None = None, callbackz: bool = False, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector (not required when ``gfirst=False``, can pass ``None``) tau : :obj:`float` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. z0 : :obj:`numpy.ndarray`, optional Initial z vector (not required when ``gfirst=True``) gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbackz : :obj:`bool`, optional Modify callback signature to (``callback(x, z)``) when ``callbackz=True`` show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, z = self.setup( proxf=proxf, proxg=proxg, x0=x0, tau=tau, z0=z0, gfirst=gfirst, niter=niter, tol=tol, callbackz=callbackz, show=show, ) x, z = self.run(x, z, niter, show=show, itershow=itershow) self.finalize(65, show) return x, z, self.iiter, self.cost
[docs] class ADMML2(Solver): r"""Alternating Direction Method of Multipliers for L2 misfit term Solves the following minimization problem using Alternating Direction Method of Multipliers: .. math:: \mathbf{x},\mathbf{z} = \argmin_{\mathbf{x},\mathbf{z}} \frac{1}{2}||\mathbf{Op}\mathbf{x} - \mathbf{b}||_2^2 + g(\mathbf{z}) \\ s.t. \; \mathbf{Ax}=\mathbf{z} where :math:`g(\mathbf{z})` is any convex function that has a known proximal operator. See Also -------- ADMM: ADMM LinearizedADMM: Linearized ADMM Notes ----- The ADMM algorithm with L2 misfit term can be expressed by the following recursion [1]_: .. math:: \mathbf{x}^{k+1} = \argmin_{\mathbf{x}} \frac{1}{2}||\mathbf{Op}\mathbf{x} - \mathbf{b}||_2^2 + \frac{1}{2\tau} ||\mathbf{Ax} - \mathbf{z}^k + \mathbf{u}^k||_2^2\\ \mathbf{z}^{k+1} = \prox_{\tau g}(\mathbf{Ax}^{k+1} + \mathbf{u}^{k})\\ \mathbf{u}^{k+1} = \mathbf{u}^{k} + \mathbf{Ax}^{k+1} - \mathbf{z}^{k+1} .. [1] S. Boyd, N. Parikh, E. Chu, B. Peleato, and J. Eckstein. 2011. Distributed optimization and statistical learning via the alternating direction method of multipliers. Foundations and Trends in Machine Learning, 3 (1), 1-122. https://doi.org/10.1561/2200000016. """ def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = ( f"Proximal operator (g): {type(self.proxg).__name__}\n" f"Linear operator (Op): {type(self.Op).__name__}\n" f"Linear operator (A): {type(self.A).__name__}\n" ) strpar1 = f"tau = {self.tau:6e}\tniter = {self.niter}\ttol = {self.tol}" print(strpar) print(strpar1) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+g" else: head1 = ( " Itn x[0] f g J=f+g" ) print(head1) def _print_step( self, x: NDArray, Ax: NDArray, pf: float | None, pg: float | None ) -> None: if self.tol is None: pf, pg = ( 0.5 * self.ncp.linalg.norm(self.Op @ x - self.b) ** 2, self.proxg(Ax), ) self.pfg = pf + pg x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e}" ) print(msg) def setup( # type: ignore[override] self, proxg: ProxOperator, Op: "LinearOperator", b: NDArray, A: "LinearOperator", x0: NDArray, tau: float, z0: NDArray | None = None, gfirst: bool = False, niter: int | None = None, tol: float | None = None, callbackz: bool = False, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function Op : :obj:`pylops.LinearOperator` Linear operator of data misfit term b : :obj:`numpy.ndarray` Data A : :obj:`pylops.LinearOperator` Linear operator of regularization term x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/\lambda_{max}(\mathbf{A}^H\mathbf{A})]`. z0 : :obj:`numpy.ndarray` Initial auxiliary vector. If ``None``, initialized to ``A @ x0``. gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbackz : :obj:`bool`, optional Modify callback signature to (``callback(x, z)``) when ``callbackz=True`` show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess z : :obj:`numpy.ndarray` Initial guess for the auxiliary variable Raises ------ ValueError If both ``x0`` and ``z0`` are set to ``None`` or ``x0`` is set to None """ self.proxg = proxg self.Op = Op self.b = b self.A = A self.tau = tau self.gfirst = gfirst self.niter = niter self.tol = tol self.callbackz = callbackz self.ncp = get_array_module(x0) # initialize solver x, z = _x0z0_init(x0, z0, A, Opname="A") self.u = self.ncp.zeros_like(z) # other parameters self.sqrttau = 1.0 / sqrt(self.tau) # create variables to track the objective function and iterations pf = 0.5 * self.ncp.linalg.norm(self.Op @ x - self.b) ** 2 pg = self.proxg(self.A @ x) pfg = pf + pg self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x, z def step( self, x: NDArray, z: NDArray, show: bool = False, **kwargs_solver: dict[str, Any], ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the ADMML2 algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the ADMML2 algorithm show : :obj:`bool`, optional Display iteration log **kwargs_solver Arbitrary keyword arguments for :py:func:`scipy.sparse.linalg.lsqr` used to solve the x-update Returns ------- x : :obj:`numpy.ndarray` Updated model vector z : :obj:`numpy.ndarray` Updated additional model vector """ if self.gfirst: Ax = self.A @ x z = self.proxg.prox(Ax + self.u, self.tau) # solve augumented system x = regularized_inversion( self.Op, self.b, [ self.A, ], x0=x, dataregs=[ z - self.u, ], epsRs=[ self.sqrttau, ], **kwargs_solver, )[0] else: # solve augumented system x = regularized_inversion( self.Op, self.b, [ self.A, ], x0=x, dataregs=[ z - self.u, ], epsRs=[ self.sqrttau, ], **kwargs_solver, )[0] Ax = self.A @ x z = self.proxg.prox(Ax + self.u, self.tau) self.u = self.u + Ax - z # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf = 0.5 * self.ncp.linalg.norm(self.Op @ x - self.b) ** 2 pg = self.proxg(Ax) self.pfg = pf + pg if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, Ax, pf, pg) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, z def run( self, x: NDArray, z: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), **kwargs_solver: dict[str, Any], ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the ADDML2 algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the ADDML2 algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. **kwargs_solver Arbitrary keyword arguments for :py:func:`scipy.sparse.linalg.lsqr` used to solve the x-update Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, z = self.step(x, z, showstep, **kwargs_solver) if self.callbackz: self.callback(x, z) else: self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, z def solve( # type: ignore[override] self, proxg: ProxOperator, Op: "LinearOperator", b: NDArray, A: "LinearOperator", x0: NDArray, tau: float, z0: NDArray | None = None, gfirst: bool = False, niter: int = 10, tol: float | None = None, callbackz: bool = False, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), **kwargs_solver: dict[str, Any], ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function Op : :obj:`pylops.LinearOperator` Linear operator of data misfit term b : :obj:`numpy.ndarray` Data A : :obj:`pylops.LinearOperator` Linear operator of regularization term x0 : :obj:`numpy.ndarray` Initial vector (not required when ``gfirst=False``, can pass ``None``) tau : :obj:`float` Positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\tau \in (0, 1/L]` where ``L`` is the Lipschitz constant of :math:`\nabla f`. z0 : :obj:`numpy.ndarray`, optional Initial z vector (not required when ``gfirst=True``) gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbackz : :obj:`bool`, optional Modify callback signature to (``callback(x, z)``) when ``callbackz=True`` show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. **kwargs_solver Arbitrary keyword arguments for :py:func:`scipy.sparse.linalg.lsqr` used to solve the x-update Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, z = self.setup( proxg=proxg, Op=Op, b=b, A=A, x0=x0, tau=tau, z0=z0, gfirst=gfirst, niter=niter, tol=tol, callbackz=callbackz, show=show, ) x, z = self.run( x, z, niter, show=show, itershow=itershow, **kwargs_solver, ) self.finalize(65, show) return x, z, self.iiter, self.cost
[docs] class LinearizedADMM(Solver): r"""Linearized Alternating Direction Method of Multipliers Solves the following minimization problem using Linearized Alternating Direction Method of Multipliers: .. math:: \mathbf{x} = \argmin_\mathbf{x} f(\mathbf{x}) + g(\mathbf{A}\mathbf{x}) where :math:`f(\mathbf{x})` and :math:`g(\mathbf{x})` are any convex function that has a known proximal operator and :math:`\mathbf{A}` is a linear operator. See Also -------- ADMM: ADMM ADMML2: ADMM with L2 misfit function Notes ----- The Linearized-ADMM algorithm can be expressed by the following recursion [1]_: .. math:: \mathbf{x}^{k+1} = \prox_{\mu f}(\mathbf{x}^{k} - \frac{\mu}{\tau} \mathbf{A}^H(\mathbf{A} \mathbf{x}^k - \mathbf{z}^k + \mathbf{u}^k))\\ \mathbf{z}^{k+1} = \prox_{\tau g}(\mathbf{A} \mathbf{x}^{k+1} + \mathbf{u}^k)\\ \mathbf{u}^{k+1} = \mathbf{u}^{k} + \mathbf{A}\mathbf{x}^{k+1} - \mathbf{z}^{k+1} .. [1] N., Parikh, "Proximal Algorithms", Foundations and Trends in Optimization. 2013. """ def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = ( f"Proximal operator (f): {type(self.proxf).__name__}\n" f"Proximal operator (g): {type(self.proxg).__name__}\n" f"Linear operator (A): {type(self.A).__name__}\n" ) strpar1 = f"tau = {self.tau:6e}\tmu = {self.mu:6e}" strpar2 = f"niter = {self.niter}\ttol = {self.tol}" print(strpar) print(strpar1) print(strpar2) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+g" else: head1 = ( " Itn x[0] f g J=f+g" ) print(head1) def _print_step(self, x: NDArray, pf: float | None, pg: float | None) -> None: if self.tol is None: pf, pg = self.proxf(x), self.proxg(self.Ax) self.pfg = pf + pg x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e}" ) print(msg) def setup( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, A: "LinearOperator", x0: NDArray, tau: float, mu: float, z0: NDArray | None = None, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function A : :obj:`pylops.LinearOperator` Linear operator x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float`, optional Positive scalar weight, which should satisfy the following condition to guarantee convergence: :math:`\mu \in (0, \tau/\lambda_{max}(\mathbf{A}^H\mathbf{A})]`. mu : :obj:`float`, optional Second positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\mu \in (0, \tau/\lambda_{max}(\mathbf{A}^H\mathbf{A})]`. z0 : :obj:`numpy.ndarray` Initial auxiliary vector. If ``None``, initialized to ``A @ x0``. niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess z : :obj:`numpy.ndarray` Initial guess for the auxiliary variable Raises ------ ValueError If both ``x0`` and ``z0`` are set to ``None`` or ``x0`` is set to None """ self.proxf = proxf self.proxg = proxg self.A = A self.tau = tau self.mu = mu self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # initialize solver x, z = _x0z0_init(x0, z0, A, Opname="A") self.Ax = A.matvec(x) if z0 is None else z self.u = self.ncp.zeros_like(z) # create variables to track the objective function and iterations pf = self.proxf(x) pg = self.proxg(self.Ax) pfg = pf + pg self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x, z def step( self, x: NDArray, z: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the LinearizedADMM algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the LinearizedADMM algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector z : :obj:`numpy.ndarray` Updated additional model vector """ x = self.proxf.prox( x - self.mu / self.tau * self.A.rmatvec(self.Ax - z + self.u), self.mu ) self.Ax = self.A.matvec(x) z = self.proxg.prox(self.Ax + self.u, self.tau) self.u = self.u + self.Ax - z # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf = self.proxf(x) pg = self.proxg(self.Ax) self.pfg = pf + pg if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, pf, pg) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, z def run( self, x: NDArray, z: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the LinearizedADDM algorithm z : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the LinearizedADDM algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, z = self.step(x, z, showstep) self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, z def solve( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, A: "LinearOperator", x0: NDArray, tau: float, mu: float, z0: NDArray | None = None, niter: int = 10, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function A : :obj:`pylops.LinearOperator` Linear operator x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float`, optional Positive scalar weight, which should satisfy the following condition to guarantee convergence: :math:`\mu \in (0, \tau/\lambda_{max}(\mathbf{A}^H\mathbf{A})]`. mu : :obj:`float`, optional Second positive scalar weight, which should satisfy the following condition to guarantees convergence: :math:`\mu \in (0, \tau/\lambda_{max}(\mathbf{A}^H\mathbf{A})]`. z0 : :obj:`numpy.ndarray` Initial auxiliary vector. If ``None``, initialized to ``A @ x0``. niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model z : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, z = self.setup( proxf=proxf, proxg=proxg, A=A, x0=x0, tau=tau, mu=mu, z0=z0, niter=niter, tol=tol, show=show, ) x, z = self.run( x, z, niter, show=show, itershow=itershow, ) self.finalize(65, show) return x, z, self.iiter, self.cost
[docs] class TwIST(Solver): r"""Two-step Iterative Shrinkage/Threshold Solves the following minimization problem using Two-step Iterative Shrinkage/Threshold: .. math:: \mathbf{x} = \argmin_\mathbf{x} \frac{1}{2} ||\mathbf{b} - \mathbf{Ax}||_2^2 + g(\mathbf{x}) where :math:`\mathbf{A}` is a linear operator and :math:`g(\mathbf{x})` is any convex function that has a known proximal operator. Notes ----- The TwIST algorithm can be expressed by the following recursion: .. math:: \mathbf{x}^{k+1} = (1-\alpha) \mathbf{x}^{k-1} + (\alpha-\beta) \mathbf{x}^k + \beta \prox_{g} (\mathbf{x}^k + \mathbf{A}^H (\mathbf{b} - \mathbf{A}\mathbf{x}^k)). where :math:`\mathbf{x}^{1} = \prox_{g} (\mathbf{x}^0 + \mathbf{A}^T (\mathbf{b} - \mathbf{A}\mathbf{x}^0))`. The optimal weighting parameters :math:`\alpha` and :math:`\beta` are linked to the smallest and largest eigenvalues of :math:`\mathbf{A}^H\mathbf{A}` as follows: .. math:: \alpha = 1 + \rho^2 \\ \beta =\frac{2 \alpha}{\Lambda_{max} + \lambda_{min}} where :math:`\rho=\frac{1-\sqrt{k}}{1+\sqrt{k}}` with :math:`k=\frac{\lambda_{min}}{\Lambda_{max}}` and :math:`\Lambda_{max}=max(1, \lambda_{max})`. Experimentally, it has been observed that TwIST is robust to the choice of such parameters. Finally, note that in the case of :math:`\alpha=1` and :math:`\beta=1`, TwIST is identical to IST. """ def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = ( f"Proximal operator (f): {type(self.proxf).__name__}\n" f"Linear operator (A): {type(self.A).__name__}\n" ) strpar1 = f"alpha = {self.alpha:6e}\tbeta = {self.beta:6e}" strpar2 = f"niter = {self.niter}\ttol = {self.tol}" print(strpar) print(strpar1) print(strpar2) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+g" else: head1 = ( " Itn x[0] f g J=f+g" ) print(head1) def _print_step(self, x: NDArray, pf: float | None, pg: float | None) -> None: if self.tol is None: pf, pg = self.proxf(x), self.proxg(x) self.pfg = pf + pg x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e}" ) print(msg) def setup( # type: ignore[override] self, proxg: ProxOperator, A: "LinearOperator", b: NDArray, x0: NDArray, alpha: float | None = None, beta: float | None = None, eigs: tuple[float, float] | None = None, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> NDArray: r"""Setup solver Parameters ---------- proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function A : :obj:`pylops.LinearOperator` Linear operator b : :obj:`numpy.ndarray` Data x0 : :obj:`numpy.ndarray` Initial vector alpha : :obj:`float`, optional Positive scalar weight (if ``None``, estimated based on the eigenvalues of :math:`\mathbf{A}`, see Notes for details) beta : :obj:`float`, optional Positive scalar weight (if ``None``, estimated based on the eigenvalues of :math:`\mathbf{A}`, see Notes for details) eigs : :obj:`tuple`, optional Largest and smallest eigenvalues of :math:`\mathbf{A}^H \mathbf{A}`. If passed, computes `alpha` and `beta` based on them. niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess """ self.proxf = L2(Op=A, b=b) self.proxg = proxg self.A = A self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # find alpha and beta based on the eigenvalues of A if not provided if alpha is None or beta is None: if eigs is None: emin = A.eigs(neigs=1, which="SM") emax = max([1, A.eigs(neigs=1, which="LM")]) else: emax, emin = eigs k = emin / emax rho = (1 - sqrt(k)) / (1 + sqrt(k)) self.alpha = 1 + rho**2 self.beta = 2 * self.alpha / (emax + emin) else: self.alpha, self.beta = alpha, beta # compute proximal of g on initial guess (x_1) x = self.proxg.prox(x0 - self.proxf.grad(x0), 1.0) # create variables to track the objective function and iterations pf = self.proxf(x) pg = self.proxg(x) pfg = pf + pg self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x def step( self, x: NDArray, xold: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the TwisT algorithm xold : :obj:`numpy.ndarray` Previous model vector show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector xold : :obj:`numpy.ndarray` Previous model vector """ # compute new x xnew = ( (1 - self.alpha) * xold + (self.alpha - self.beta) * x + self.beta * self.proxg.prox(x - self.proxf.grad(x), 1.0) ) # save current x as old (x_i -> x_i-1) xold = x.copy() # save new x as current (x_i+1 -> x_i) x = xnew.copy() # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf = self.proxf(x) pg = self.proxg(x) self.pfg = pf + pg if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, pf, pg) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, xold def run( self, x: NDArray, xold: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> NDArray: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the TwisT algorithm xold : :obj:`numpy.ndarray` Previous estimated model niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, xold = self.step(x, xold, showstep) self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x def solve( # type: ignore[override] self, proxg: ProxOperator, A: "LinearOperator", b: NDArray, x0: NDArray, alpha: float | None = None, beta: float | None = None, eigs: tuple[float, float] | None = None, niter: int = 10, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function A : :obj:`pylops.LinearOperator` Linear operator b : :obj:`numpy.ndarray` Data x0 : :obj:`numpy.ndarray` Initial vector alpha : :obj:`float`, optional Positive scalar weight (if ``None``, estimated based on the eigenvalues of :math:`\mathbf{A}`, see Notes for details) beta : :obj:`float`, optional Positive scalar weight (if ``None``, estimated based on the eigenvalues of :math:`\mathbf{A}`, see Notes for details) eigs : :obj:`tuple`, optional Largest and smallest eigenvalues of :math:`\mathbf{A}^H \mathbf{A}`. If passed, computes `alpha` and `beta` based on them. niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x = self.setup( proxg=proxg, A=A, b=b, x0=x0, alpha=alpha, beta=beta, eigs=eigs, niter=niter, tol=tol, show=show, ) x = self.run( x, x0, niter, show=show, itershow=itershow, ) self.finalize(65, show) return x, self.iiter, self.cost
[docs] class DouglasRachfordSplitting(Solver): r"""Douglas-Rachford Splitting Solves the following minimization problem using Douglas-Rachford Splitting algorithm: .. math:: \mathbf{x} = \argmin_\mathbf{x} f(\mathbf{x}) + g(\mathbf{x}) where :math:`f(\mathbf{x})` and :math:`g(\mathbf{x})` are any convex functions that has known proximal operators. Notes ----- The Douglas-Rachford Splitting algorithm can be expressed by the following recursion [1]_, [2]_, [3]_, [4]_: .. math:: \mathbf{x}^{k} &= \prox_{\tau g}(\mathbf{y}^k) \\ \mathbf{y}^{k+1} &= \mathbf{y}^{k} + \eta (\prox_{\tau f}(2 \mathbf{x}^{k} - \mathbf{y}^{k}) - \mathbf{x}^{k}) .. [1] Patrick L. Combettes and Jean-Christophe Pesquet. 2011. Proximal Splitting Methods in Signal Processing. In Fixed-Point Algorithms for Inverse Problems in Science and Engineering, Springer, pp. 185-212. Algorithm 10.15. https://doi.org/10.1007/978-1-4419-9569-8_10 .. [2] Scott B. Lindstrom and Brailey Sims. 2021. Survey: Sixty Years of Douglas-Rachford. Journal of the Australian Mathematical Society, 110, 3, 333-370. Eq.(15). https://doi.org/10.1017/S1446788719000570 https://arxiv.org/abs/1809.07181 .. [3] Ryu, E.K., Yin, W., 2022. Large-Scale Convex Optimization: Algorithms & Analyses via Monotone Operators. Cambridge University Press, Cambridge. Eq.(2.18). https://doi.org/10.1017/9781009160865 https://large-scale-book.mathopt.com/ .. [4] Combettes, P.L., Pesquet, J.-C., 2008. A proximal decomposition method for solving convex variational inverse problems. Inverse Problems 24, 065014. Proposition 3.2. https://doi.org/10.1088/0266-5611/24/6/065014 https://arxiv.org/abs/0807.2617 """ def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = ( f"Proximal operator (f): {type(self.proxf).__name__}\n" f"Proximal operator (g): {type(self.proxg).__name__}\n" ) strpar1 = f"tau = {self.tau:6e}\teta = {self.eta:6e}\tniter = {self.niter}" strpar2 = f"gfirst = {self.gfirst}\t\ttol = {self.tol}" print(strpar) print(strpar1) print(strpar2) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] f g J=f+g" else: head1 = ( " Itn x[0] f g J=f+g" ) print(head1) def _print_step(self, x: NDArray, pf: float | None, pg: float | None) -> None: if self.tol is None: pf, pg = self.proxf(x), self.proxg(x) self.pfg = pf + pg x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = ( f"{self.iiter:6g} " + strx + f"{pf:10.3e} {pg:10.3e} {self.pfg:10.3e}" ) print(msg) def setup( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, tau: float, eta: float = 1.0, gfirst: bool = True, niter: int | None = None, tol: float | None = None, callbacky: bool = False, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float` Positive scalar weight eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 2, 0 excluded). gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbacky : :obj:`bool`, optional Modify callback signature to (``callback(x, y)``) when ``callbacky=True`` show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess y : :obj:`numpy.ndarray` Initial guess for the auxiliary variable """ self.proxf = proxf self.proxg = proxg self.tau = tau self.eta = eta self.gfirst = gfirst self.niter = niter self.tol = tol self.callbacky = callbacky self.ncp = get_array_module(x0) # initialize solver x = x0.copy() y = x0.copy() # create variables to track the objective function and iterations pf = self.proxf(x) pg = self.proxg(x) pfg = pf + pg self.pfg, self.pfgold = pfg, pfg self.cost: list[float] = [] self.cost.append(float(self.pfg)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x, y def step( self, x: NDArray, y: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the DouglasRachfordSplitting algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the DouglasRachfordSplitting algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector y : :obj:`numpy.ndarray` Updated additional model vector """ # proximal steps if self.gfirst: x = self.proxg.prox(y, self.tau) y = y + self.eta * (self.proxf.prox(2 * x - y, self.tau) - x) else: x = self.proxf.prox(y, self.tau) y = y + self.eta * (self.proxg.prox(2 * x - y, self.tau) - x) # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfgold = self.pfg pf = self.proxf(x) pg = self.proxg(x) self.pfg = pf + pg if ( self.pfgold != 0 and self.ncp.abs(1.0 - self.pfg / self.pfgold) < self.tol ): self.tolbreak = True else: pf, pg = 0.0, 0.0 self.iiter += 1 if show: self._print_step(x, pf, pg) if self.tol is not None or show: self.cost.append(float(self.pfg)) return x, y def run( self, x: NDArray, y: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the DouglasRachfordSplitting algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the DouglasRachfordSplitting algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, y = self.step(x, y, showstep) if self.callbacky: self.callback(x, y) else: self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, y def solve( # type: ignore[override] self, proxf: ProxOperator, proxg: ProxOperator, x0: NDArray, tau: float, eta: float = 1.0, gfirst: bool = True, niter: int = 10, tol: float | None = None, callbacky: bool = False, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxf : :obj:`pyproximal.ProxOperator` Proximal operator of f function proxg : :obj:`pyproximal.ProxOperator` Proximal operator of g function x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float` Positive scalar weight eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 2, 0 excluded). gfirst : :obj:`bool`, optional Apply Proximal of operator ``g`` first (``True``) or Proximal of operator ``f`` first (``False``) niter : :obj:`int`, optional Number of iterations of iterative scheme tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached callbacky : :obj:`bool`, optional Modify callback signature to (``callback(x, y)``) when ``callbacky=True`` show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, y = self.setup( proxf=proxf, proxg=proxg, x0=x0, tau=tau, eta=eta, gfirst=gfirst, niter=niter, tol=tol, callbacky=callbacky, show=show, ) x, y = self.run(x, y, niter, show=show, itershow=itershow) self.finalize(65, show) return x, y, self.iiter, self.cost
[docs] class PPXA(Solver): r"""Parallel Proximal Algorithm (PPXA) Solves the following minimization problem using Parallel Proximal Algorithm (PPXA): .. math:: \mathbf{x} = \argmin_\mathbf{x} \sum_{i=1}^m f_i(\mathbf{x}) where :math:`f_i(\mathbf{x})` are any convex functions that has known proximal operators. See Also -------- ConsensusADMM: Consensus ADMM Notes ----- The Parallel Proximal Algorithm (PPXA) can be expressed by the following recursion [1]_, [2]_, [3]_, [4]_: * :math:`\mathbf{y}_{i}^{0} = \mathbf{x}` or :math:`\mathbf{y}_{i}^{0} = \mathbf{x}_{i}` for :math:`i=1,\ldots,m` * :math:`\mathbf{x}^{0} = \sum_{i=1}^m w_i \mathbf{y}_{i}^{0}` * for :math:`k = 1, \ldots` * for :math:`i = 1, \ldots, m` * :math:`\mathbf{p}_{i}^{k} = \prox_{\frac{\tau}{w_i} f_i} (\mathbf{y}_{i}^{k})` * :math:`\mathbf{p}^{k} = \sum_{i=1}^{m} w_i \mathbf{p}_{i}^{k}` * for :math:`i = 1, \ldots, m` * :math:`\mathbf{y}_{i}^{k+1} = \mathbf{y}_{i}^{k} + \eta (2 \mathbf{p}^{k} - \mathbf{x}^{k} - \mathbf{p}_i^{k})` * :math:`\mathbf{x}^{k+1} = \mathbf{x}^{k} + \eta (\mathbf{p}^{k} - \mathbf{x}^{k})` where :math:`0 < \eta < 2` and :math:`\sum_{i=1}^m w_i = 1, \ 0 < w_i < 1`. In the current implementation, :math:`w_i = 1 / m` when not provided. References ---------- .. [1] Combettes, P.L., Pesquet, J.-C., 2008. A proximal decomposition method for solving convex variational inverse problems. Inverse Problems 24, 065014. Algorithm 3.1. https://doi.org/10.1088/0266-5611/24/6/065014 https://arxiv.org/abs/0807.2617 .. [2] Combettes, P.L., Pesquet, J.-C., 2011. Proximal Splitting Methods in Signal Processing, in Fixed-Point Algorithms for Inverse Problems in Science and Engineering, Springer, pp. 185-212. Algorithm 10.27. https://doi.org/10.1007/978-1-4419-9569-8_10 .. [3] Bauschke, H.H., Combettes, P.L., 2011. Convex Analysis and Monotone Operator Theory in Hilbert Spaces, 1st ed, CMS Books in Mathematics. Springer, New York, NY. Proposition 27.8. https://doi.org/10.1007/978-1-4419-9467-7 .. [4] Ryu, E.K., Yin, W., 2022. Large-Scale Convex Optimization: Algorithms & Analyses via Monotone Operators. Cambridge University Press, Cambridge. Exercise 2.38 https://doi.org/10.1017/9781009160865 https://large-scale-book.mathopt.com/ """ def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = "\n".join( [ f"Proximal operator (f{i}): {type(proxf).__name__}" for i, proxf in enumerate(self.proxfs) ] ) strpar1 = f"tau = {self.tau:6e}\teta = {self.eta:6e}" strpar2 = f"weights = {self.weights}" strpar3 = f"niter = {self.niter}\ttol = {self.tol}" print(strpar) print(strpar1) print(strpar2) print(strpar3) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] J=sum_i f_i" else: head1 = " Itn x[0] J=sum_i f_i" print(head1) def _print_step(self, x: NDArray) -> None: if self.tol is None: self.pf = self.ncp.sum([self.proxfs[i](x) for i in range(self.nprox)]) x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = f"{self.iiter:6g} " + strx + f"{self.pf:10.3e}" print(msg) def setup( # type: ignore[override] self, proxfs: list[ProxOperator], x0: NDArray | list[NDArray], tau: float, eta: float = 1.0, weights: NDArray | list[float] | None = None, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> tuple[NDArray, NDArray]: r"""Setup solver Parameters ---------- proxfs : :obj:`list` A list of proximable functions :math:`f_1, \ldots, f_m`. x0 : :obj:`numpy.ndarray` or :obj:`list` Initial vector :math:`\mathbf{x}` for all :math:`f_i` if 1-dimensional array is provided, or initial vectors :math:`\mathbf{x}_{i}` for each :math:`f_i` for :math:`i=1,\ldots,m` if a :obj:`list` of 1-dimensional arrays or a 2-dimensional array of size ``(m, d)`` is provided, where ``d`` is the dimension of :math:`\mathbf{x}_{i}`. tau : :obj:`float` Positive scalar weight eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 2, 0 excluded). weights : :obj:`numpy.ndarray` or :obj:`list` or :obj:`None`, optional Weights :math:`\sum_{i=1}^m w_i = 1, \ 0 < w_i < 1`, Defaults to None, which means :math:`w_1 = \cdots = w_m = \frac{1}{m}.` niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess y : :obj:`numpy.ndarray` Initial guess for the auxiliary variable(s) """ self.proxfs = proxfs self.tau = tau self.eta = eta self.weights = weights self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # initialize solver self.nprox = len(proxfs) if weights is None: self.w = self.ncp.full(self.nprox, 1.0 / self.nprox) else: self.w = self.ncp.asarray(weights) if isinstance(x0, list) or x0.ndim == 2: y = self.ncp.asarray(x0) # yi_0 = xi_0, for i = 1, ..., m else: y = self.ncp.full( (self.nprox, x0.size), x0 ) # y1_0 = y2_0 = ... = ym_0 = x0 x = self.ncp.mean(y, axis=0) # create variables to track the objective function and iterations pf = self.ncp.sum([self.proxfs[i](x) for i in range(self.nprox)]) self.pf, self.pfold = pf, pf self.cost: list[float] = [] self.cost.append(float(self.pf)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x, y def step( self, x: NDArray, y: NDArray, show: bool = False ) -> tuple[NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the PPXA algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by a step of the PPXA algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vector y : :obj:`numpy.ndarray` Updated additional model vector """ # store current solution prior to update # for tolerance check self.xold = x # proximal steps p = self.ncp.stack( [self.proxfs[i].prox(y[i], self.tau / self.w[i]) for i in range(self.nprox)] ) pn = self.ncp.sum(self.w[:, None] * p, axis=0) y = y + self.eta * (2 * pn - x - p) x = x + self.eta * (pn - x) # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfold = self.pf self.pf = self.ncp.sum([self.proxfs[i](x) for i in range(self.nprox)]) if self.pfold != 0 and self.ncp.abs(1.0 - self.pf / self.pfold) < self.tol: self.tolbreak = True else: self.pf = 0.0 self.iiter += 1 if show: self._print_step(x) if self.tol is not None or show: self.cost.append(float(self.pf)) return x, y def run( self, x: NDArray, y: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the PPXA algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the PPXA algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model(s) """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, y = self.step(x, y, showstep) self.callback(x) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, y def solve( # type: ignore[override] self, proxfs: list[ProxOperator], x0: NDArray | list[NDArray], tau: float, eta: float = 1.0, weights: NDArray | list[float] | None = None, niter: int = 1000, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxfs : :obj:`list` A list of proximable functions :math:`f_1, \ldots, f_m`. x0 : :obj:`numpy.ndarray` or :obj:`list` Initial vector :math:`\mathbf{x}` for all :math:`f_i` if 1-dimensional array is provided, or initial vectors :math:`\mathbf{x}_{i}` for each :math:`f_i` for :math:`i=1,\ldots,m` if a :obj:`list` of 1-dimensional arrays or a 2-dimensional array of size ``(m, d)`` is provided, where ``d`` is the dimension of :math:`\mathbf{x}_{i}`. tau : :obj:`float` Positive scalar weight eta : :obj:`float`, optional Relaxation parameter (must be between 0 and 2, 0 excluded). weights : :obj:`numpy.ndarray` or :obj:`list` or :obj:`None`, optional Weights :math:`\sum_{i=1}^m w_i = 1, \ 0 < w_i < 1`, Defaults to None, which means :math:`w_1 = \cdots = w_m = \frac{1}{m}.` niter : :obj:`int`, optional Number of iterations of iterative scheme. tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model y : :obj:`numpy.ndarray` Additional estimated model(s) iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, y = self.setup( proxfs=proxfs, x0=x0, tau=tau, eta=eta, weights=weights, niter=niter, tol=tol, show=show, ) x, y = self.run(x, y, niter, show=show, itershow=itershow) self.finalize(65, show) return x, y, self.iiter, self.cost
[docs] class ConsensusADMM(Solver): r"""Consensus ADMM Solves the following global consensus problem using ADMM: .. math:: \argmin_{\mathbf{x_1}, \mathbf{x_2}, \ldots, \mathbf{x_m}} \sum_{i=1}^m f_i(\mathbf{x}_i) \quad \text{s.t.} \quad \mathbf{x_1} = \mathbf{x_2} = \cdots = \mathbf{x_m} where :math:`f_i(\mathbf{x})` are any convex functions that has known proximal operators. See Also -------- ADMM: Alternating Direction Method of Multipliers PPXA: Parallel Proximal Algorithm Notes ----- The ADMM for the consensus problem can be expressed by the following recursion [1]_, [2]_: * :math:`\bar{\mathbf{x}}^{0} = \mathbf{x}` * for :math:`k = 1, \ldots` * for :math:`i = 1, \ldots, m` * :math:`\mathbf{x}_i^{k+1} = \mathrm{prox}_{\tau f_i} \left(\bar{\mathbf{x}}^{k} - \mathbf{y}_i^{k}\right)` * :math:`\bar{\mathbf{x}}^{k+1} = \frac{1}{m} \sum_{i=1}^m \mathbf{x}_i^{k}` * for :math:`i = 1, \ldots, m` * :math:`\mathbf{y}_i^{k+1} = \mathbf{y}_i^{k} + \mathbf{x}_i^{k+1} - \bar{\mathbf{x}}^{k+1}` The current implementation returns :math:`\bar{\mathbf{x}}`. References ---------- .. [1] Boyd, S., Parikh, N., Chu, E., Peleato, B., Eckstein, J., 2011. Distributed Optimization and Statistical Learning via the Alternating Direction Method of Multipliers. Foundations and Trends in Machine Learning, Vol. 3, No. 1, pp 1-122. Section 7.1. https://doi.org/10.1561/2200000016 https://stanford.edu/~boyd/papers/pdf/admm_distr_stats.pdf .. [2] Parikh, N., Boyd, S., 2014. Proximal Algorithms. Foundations and Trends in Optimization, Vol. 1, No. 3, pp 127-239. Section 5.2.1. https://doi.org/10.1561/2400000003 https://web.stanford.edu/~boyd/papers/pdf/prox_algs.pdf """ def _print_setup(self, xcomplex: bool = False) -> None: self._print_solver(nbar=65) strpar = "\n".join( [ f"Proximal operator (f{i}): {type(proxf).__name__}" for i, proxf in enumerate(self.proxfs) ] ) strpar1 = f"tau = {self.tau:6e}\tniter = {self.niter}\ttol = {self.tol}" print(strpar) print(strpar1) print("-" * 65 + "\n") if not xcomplex: head1 = " Itn x[0] J=sum_i f_i" else: head1 = " Itn x[0] J=sum_i f_i" print(head1) def _print_step(self, x: NDArray) -> None: if self.tol is None: self.pf = self.ncp.sum([self.proxfs[i](x) for i in range(self.nprox)]) x0 = to_numpy(x[0]) strx = f"{x0:1.2e} " if np.iscomplexobj(x) else f"{x0:11.4e} " msg = f"{self.iiter:6g} " + strx + f"{self.pf:10.3e}" print(msg) def setup( # type: ignore[override] self, proxfs: list[ProxOperator], x0: NDArray, tau: float, niter: int | None = None, tol: float | None = None, show: bool = False, ) -> tuple[NDArray, NDArray, NDArray]: r"""Setup solver Parameters ---------- proxfs : :obj:`list` A list of proximable functions :math:`f_1, \ldots, f_m`. x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float` Positive scalar weight niter : :obj:`int`, optional Number of iterations (default to ``None`` in case a user wants to manually step over the solver) tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display iterations log Returns ------- x : :obj:`numpy.ndarray` Initial guess x_bar : :obj:`numpy.ndarray` Initial guess for averaged model vector y : :obj:`numpy.ndarray` Initial guess for additional model vector """ self.proxfs = proxfs self.tau = tau self.niter = niter self.tol = tol self.ncp = get_array_module(x0) # initialize solver self.nprox = len(proxfs) x = x0.copy() x_bar = x0.copy() y = self.ncp.zeros((self.nprox, x0.size), dtype=x0.dtype) # create variables to track the objective function and iterations pf = self.ncp.sum([self.proxfs[i](x_bar) for i in range(self.nprox)]) self.pf, self.pfold = pf, pf self.cost: list[float] = [] self.cost.append(float(self.pf)) self.tolbreak = False self.iiter = 0 # print setup if show: self._print_setup(np.iscomplexobj(x0)) return x, x_bar, y def step( self, x: NDArray, x_bar: NDArray, y: NDArray, show: bool = False, ) -> tuple[NDArray, NDArray, NDArray]: r"""Run one step of solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by a step of the ConsensusADMM algorithm x_bar : :obj:`numpy.ndarray` Current averaged model vector to be updated by a step of the ConsensusADMM algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the ConsensusADMM algorithm show : :obj:`bool`, optional Display iteration log Returns ------- x : :obj:`numpy.ndarray` Updated model vectors x_bar : :obj:`numpy.ndarray` Updated averaged model vector y : :obj:`numpy.ndarray` Updated additional model vector """ # store current solution prior to update # for tolerance check self.xold = x # proximal steps x = self.ncp.stack( [self.proxfs[i].prox(x_bar - y[i], self.tau) for i in range(self.nprox)] ) x_bar = self.ncp.mean(x, axis=0) y = y + x - x_bar # tolerance check: break iterations if overall # objective does not decrease below tolerance if self.tol is not None: self.pfold = self.pf self.pf = self.ncp.sum([self.proxfs[i](x_bar) for i in range(self.nprox)]) if self.pfold != 0 and self.ncp.abs(1.0 - self.pf / self.pfold) < self.tol: self.tolbreak = True else: self.pf = 0.0 self.iiter += 1 if show: self._print_step(x_bar) if self.tol is not None or show: self.cost.append(float(self.pf)) return x, x_bar, y def run( self, x: NDArray, x_bar: NDArray, y: NDArray, niter: int | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, NDArray]: r"""Run solver Parameters ---------- x : :obj:`numpy.ndarray` Current model vector to be updated by multiple steps of the ConsensusADMM algorithm x_bar : :obj:`numpy.ndarray` Current averaged model vector to be updated by multiple steps of the ConsensusADMM algorithm y : :obj:`numpy.ndarray` Additional model vector to be updated by multiple steps of the ConsensusADMM algorithm niter : :obj:`int`, optional Number of iterations. Can be set to ``None`` if already provided in the setup call show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model x_bar : :obj:`numpy.ndarray` Estimated averaged model y : :obj:`numpy.ndarray` Additional estimated model """ niter = self.niter if niter is None else niter if niter is None: msg = "`niter` must not be None" raise ValueError(msg) while self.iiter < niter and not self.tolbreak: showstep = ( True if show and ( self.iiter < itershow[0] or niter - self.iiter < itershow[1] or self.iiter % itershow[2] == 0 ) else False ) x, x_bar, y = self.step(x, x_bar, y, showstep) self.callback(x_bar) # check if any callback has raised a stop flag stop = _callback_stop(self.callbacks) if stop: break return x, x_bar, y def solve( # type: ignore[override] self, proxfs: list[ProxOperator], x0: NDArray, tau: float, niter: int = 1000, tol: float | None = None, show: bool = False, itershow: tuple[int, int, int] = (10, 10, 10), ) -> tuple[NDArray, NDArray, NDArray, int, NDArray]: r"""Run entire solver Parameters ---------- proxfs : :obj:`list` A list of proximable functions :math:`f_1, \ldots, f_m`. x0 : :obj:`numpy.ndarray` Initial vector tau : :obj:`float` Positive scalar weight niter : :obj:`int`, optional Number of iterations of iterative scheme. tol : :obj:`float`, optional Tolerance on change of objective function (used as stopping criterion). If ``tol=None``, run until ``niter`` is reached show : :obj:`bool`, optional Display logs itershow : :obj:`tuple`, optional Display set log for the first N1 steps, last N2 steps, and every N3 steps in between where N1, N2, N3 are the three element of the list. Returns ------- x : :obj:`numpy.ndarray` Estimated model x_bar : :obj:`numpy.ndarray` Estimated averaged model y : :obj:`numpy.ndarray` Additional estimated model iiter : :obj:`int` Number of executed iterations cost : :obj:`numpy.ndarray` History of the objective function """ x, x_bar, y = self.setup( proxfs=proxfs, x0=x0, tau=tau, niter=niter, tol=tol, show=show, ) x, x_bar, y = self.run(x, x_bar, y, niter, show=show, itershow=itershow) self.finalize(65, show) return x, x_bar, y, self.iiter, self.cost
# TOD0: # - make niter optional in setup and fix and prints # in _print_setup to not include if not provided