# -*- coding: utf-8 -*-
r"""
This module implements solver objects who minimize an objective function. Call
:func:`solve` to solve your convex optimization problem using your instantiated
solver and functions objects. The :class:`solver` base class defines the
interface of all solver objects. The specialized solver objects inherit from
it and implement the class methods. The following solvers are included :
* :class:`forward_backward`: Forward-backward proximal splitting algorithm.
* :class:`douglas_rachford`: Douglas-Rachford proximal splitting algorithm.
"""
import numpy as np
import time
from pyunlocbox.functions import dummy
[docs]def solve(functions, x0, solver=None, rtol=1e-3, atol=float('-inf'),
convergence_speed=float('-inf'), maxit=200, verbosity='LOW'):
r"""
Solve an optimization problem whose objective function is the sum of some
convex functions.
This function minimizes the objective function :math:`f(x) =
\sum\limits_{m=0}^{m=M} f_m(x)`, i.e. solves
:math:`\operatorname{arg\,min}\limits_x f(x)` for
:math:`x \in \mathbb{R}^N` using whatever algorithm. It returns a
dictionary with the found solution and some informations about the
algorithm execution.
Parameters
----------
functions : list of objects
A list of convex functions to minimize. These are objects who must
implement the :meth:`pyunlocbox.functions.func.eval` method. The
:meth:`pyunlocbox.functions.func.grad` and / or
:meth:`pyunlocbox.functions.func.prox` methods are required by some
solvers. Note also that some solvers can only handle two convex
functions while others may handle more. Please refer to the
documentation of the considered solver.
x0 : array_like
Starting point of the algorithm, :math:`x_0 \in \mathbb{R}^N`.
solver : solver class instance, optional
The solver algorithm. It is an object who must inherit from
:class:`pyunlocbox.solvers.solver` and implement the :meth:`_pre`,
:meth:`_algo` and :meth:`_post` methods. If no solver object are
provided, a standard one will be chosen given the number of convex
function objects and their implemented methods.
rtol : float, optional
The convergence (relative tolerance) stopping criterion. The algorithm
stops if :math:`\left|\frac{n(k-1)-n(k)}{n(k)}\right|<rtol` where
:math:`n(k)=f(x)` is the objective function at iteration :math:`k`.
Default is :math:`10^{-3}`.
atol : float, optional
The absolute tolerance stopping criterion. The algorithm stops if
:math:`n(k)<atol`. Default is minus infinity.
convergence_speed : float, optional
The minimum tolerable convergence speed of the objective function. The
algorithm stops if n(k-1) - n(k) < `convergence_speed`. Default is
minus infinity (i.e. the objective function may even increase).
maxit : int, optional
The maximum number of iterations. Default is 200.
verbosity : {'NONE', 'LOW', 'HIGH', 'ALL'}, optional
The log level : ``'NONE'`` for no log, ``'LOW'`` for resume at
convergence, ``'HIGH'`` for info at all solving steps, ``'ALL'`` for
all possible outputs, including at each steps of the proximal operators
computation. Default is ``'LOW'``.
Returns
-------
sol : ndarray
The problem solution.
solver : str
The used solver.
niter : int
The number of iterations.
time : float
The execution time in seconds.
eval : float
The final evaluation of the objective function :math:`f(x)`.
crit : {'MAXIT', 'ATOL', 'RTOL', 'CONVSPEED'}
The used stopping criterion. 'MAXIT' if the maximum number of
iterations `maxit` is reached, 'ATOL' if the objective function
value is smaller than `atol`, 'RTOL' if the relative objective
function improvement was smaller than `rtol` (i.e. the algorithm
converged), 'CONVSPEED' if the objective function improvement is
smaller than `convergence_speed`.
rel : float
The relative objective improvement at convergence.
objective : ndarray
The successive evaluations of the objective function at each iteration.
Examples
--------
>>> import pyunlocbox
>>> f = pyunlocbox.functions.norm_l2(y=[4, 5, 6, 7])
>>> ret = pyunlocbox.solvers.solve([f], [0, 0, 0, 0], atol=1e-5)
INFO: Dummy objective function added.
INFO: Selected solver : forward_backward
Solution found after 10 iterations :
objective function f(sol) = 7.460428e-09
last relative objective improvement : 1.624424e+03
stopping criterion : ATOL
>>> ret['sol']
array([ 3.99996922, 4.99996153, 5.99995383, 6.99994614])
"""
if rtol < 0 or maxit < 0:
raise ValueError('Parameters should be positive numbers.')
if verbosity not in ['NONE', 'LOW', 'HIGH', 'ALL']:
raise ValueError('Verbosity should be either NONE, LOW, HIGH or ALL.')
# Add a second dummy convex function if only one function is provided.
if len(functions) < 1:
raise ValueError('At least 1 convex function should be provided.')
elif len(functions) == 1:
functions.append(dummy())
if verbosity in ['LOW', 'HIGH', 'ALL']:
print('INFO: Dummy objective function added.')
# Choose a solver if none provided.
if not solver:
fb0 = 'GRAD' in functions[0].cap(x0) and 'PROX' in functions[1].cap(x0)
fb1 = 'GRAD' in functions[1].cap(x0) and 'PROX' in functions[0].cap(x0)
dg0 = 'PROX' in functions[0].cap(x0) and 'PROX' in functions[1].cap(x0)
if len(functions) == 2:
if fb0 or fb1:
solver = forward_backward() # Need one prox and 1 grad.
elif dg0:
solver = douglas_rachford() # Need two prox.
else:
raise ValueError('No suitable solver for the given functions.')
elif len(functions) > 2:
raise NotImplementedError('No solver able to minimize more than 2 '
'functions for now.')
if verbosity in ['LOW', 'HIGH', 'ALL']:
print('INFO: Selected solver : %s' % (solver.__class__.__name__,))
# Set solver and functions verbosity.
translation = {'ALL': 'HIGH', 'HIGH': 'HIGH', 'LOW': 'LOW', 'NONE': 'NONE'}
solver.verbosity = translation[verbosity]
translation = {'ALL': 'HIGH', 'HIGH': 'LOW', 'LOW': 'NONE', 'NONE': 'NONE'}
functions_verbosity = []
for f in functions:
functions_verbosity.append(f.verbosity)
f.verbosity = translation[verbosity]
tstart = time.time()
crit = None
niter = 0
objective = [[f.eval(x0) for f in functions]]
# Solver specific initialization.
solver.pre(functions, x0)
while not crit:
niter += 1
if verbosity in ['HIGH', 'ALL']:
print('Iteration %d of %s :' % (niter, solver.__class__.__name__))
# Solver iterative algorithm.
solver.algo(objective, niter)
objective.append([f.eval(solver.sol) for f in functions])
current = np.sum(objective[-1])
last = np.sum(objective[-2])
# Prevent division by 0.
div = current
if div == 0:
if verbosity in ['LOW', 'HIGH', 'ALL']:
print('WARNING: objective function is equal to 0 !')
if last != 0:
div = last
else:
div = 1.0 # Result will be zero anyway.
relative = np.abs((last - current) / div)
# Verify stopping criteria.
if current < atol:
crit = 'ATOL'
elif relative < rtol:
crit = 'RTOL'
elif niter >= maxit:
crit = 'MAXIT'
elif last - current < convergence_speed:
crit = 'CONVSPEED'
if verbosity in ['HIGH', 'ALL']:
print(' objective = %.2e, relative = %.2e'
% (current, relative))
# Solver specific post-processing.
solver.post()
# Restore verbosity for functions. In case they are called outside solve().
for k, f in enumerate(functions):
f.verbosity = functions_verbosity[k]
if verbosity in ['LOW', 'HIGH', 'ALL']:
print('Solution found after %d iterations :' % (niter,))
print(' objective function f(sol) = %e' % (current,))
print(' last relative objective improvement : %e' % (relative,))
print(' stopping criterion : %s' % (crit,))
# Returned dictionary.
result = {'sol': solver.sol,
'solver': solver.__class__.__name__, # algo for consistency ?
'niter': niter,
'time': time.time() - tstart,
'eval': current,
'objective': objective,
'crit': crit,
'rel': relative}
return result
[docs]class solver(object):
r"""
Defines the solver object interface.
This class defines the interface of a solver object intended to be passed
to the :func:`pyunlocbox.solvers.solve` solving function. It is intended to
be a base class for standard solvers which will implement the required
methods. It can also be instantiated by user code and dynamically modified
for rapid testing. This class also defines the generic attributes of all
solver objects.
Parameters
----------
gamma : float
The step size. This parameter is upper bounded by
:math:`\frac{1}{\beta}` where the second convex function (gradient ?)
is :math:`\beta` Lipschitz continuous. Default is 1.
post_gamma : function
User defined function to post-process the step size. This function is
called every iteration and permits the user to alter the solver
algorithm. The user may start with a high step size and progressively
lower it while the algorithm runs to accelerate the convergence. The
function parameters are the following : `gamma` (current step size),
`sol` (current problem solution), `objective` (list of successive
evaluations of the objective function), `niter` (current iteration
number). The function should return a new value for `gamma`. Default is
to return an unchanged value.
post_sol : function
User defined function to post-process the problem solution. This
function is called every iteration and permits the user to alter the
solver algorithm. Same parameter as :func:`post_gamma`. Default is to
return an unchanged value.
"""
def __init__(self, gamma=1, post_gamma=None, post_sol=None):
if gamma < 0:
raise ValueError('Gamma should be a positive number.')
self.gamma = gamma
if post_gamma:
self.post_gamma = post_gamma
else:
self.post_gamma = lambda gamma, sol, objective, niter: gamma
if post_sol:
self.post_sol = post_sol
else:
self.post_sol = lambda gamma, sol, objective, niter: sol
[docs] def pre(self, functions, x0):
"""
Solver specific initialization. See parameters documentation in
:func:`pyunlocbox.solvers.solve` documentation.
"""
self._pre(functions, x0)
def _pre(self, x0):
raise NotImplementedError("Class user should define this method.")
[docs] def algo(self, objective, niter):
"""
Call the solver iterative algorithm while allowing the user to alter
it. This makes it possible to dynamically change the `gamma` step size
while the algorithm is running. See parameters documentation in
:func:`pyunlocbox.solvers.solve` documentation.
"""
self._algo()
self.gamma = self.post_gamma(self.gamma, self.sol, objective, niter)
self.sol = self.post_sol(self.gamma, self.sol, objective, niter)
def _algo(self):
raise NotImplementedError("Class user should define this method.")
[docs] def post(self):
"""
Solver specific post-processing. See parameters documentation in
:func:`pyunlocbox.solvers.solve` documentation.
"""
self._post()
def _post(self):
# Do not need to be necessarily implemented by class user.
pass
[docs]class forward_backward(solver):
r"""
Forward-backward proximal splitting algorithm.
This algorithm solves convex optimization problems composed of the sum of
two objective functions.
See generic attributes descriptions of the
:class:`pyunlocbox.solvers.solver` base class.
Parameters
----------
method : {'FISTA', 'ISTA'}, optional
The method used to solve the problem. It can be 'FISTA' or 'ISTA'.
Default is 'FISTA'.
lambda_ : float, optional
The update term weight for ISTA. It should be between 0 and 1. Default
is 1.
Notes
-----
This algorithm requires one function to implement the
:meth:`pyunlocbox.functions.func.prox` method and the other one to
implement the :meth:`pyunlocbox.functions.func.grad` method.
Examples
--------
>>> from pyunlocbox import functions, solvers
>>> import numpy as np
>>> y = [4, 5, 6, 7]
>>> x0 = np.zeros(len(y))
>>> f1 = functions.norm_l2(y=y)
>>> f2 = functions.dummy()
>>> solver = solvers.forward_backward(method='FISTA', lambda_=1, gamma=1)
>>> ret = solvers.solve([f1, f2], x0, solver, atol=1e-5)
Solution found after 10 iterations :
objective function f(sol) = 7.460428e-09
last relative objective improvement : 1.624424e+03
stopping criterion : ATOL
>>> ret['sol']
array([ 3.99996922, 4.99996153, 5.99995383, 6.99994614])
"""
def __init__(self, method='FISTA', lambda_=1, *args, **kwargs):
super(forward_backward, self).__init__(*args, **kwargs)
self.method = method
self.lambda_ = lambda_
def _pre(self, functions, x0):
if self.verbosity is 'HIGH':
print('INFO: Forward-backward method : %s' % (self.method,))
if self.lambda_ < 0 or self.lambda_ > 1:
raise ValueError('Lambda is bounded by 0 and 1.')
# ISTA and FISTA initialization.
self.sol = np.array(x0)
if self.method is 'ISTA':
self._algo = self._ista
elif self.method is 'FISTA':
self._algo = self._fista
self.un = np.array(x0)
self.tn = 1.
else:
raise ValueError('The method should be either FISTA or ISTA.')
if len(functions) != 2:
raise ValueError('Forward-backward requires two convex functions.')
if 'PROX' in functions[0].cap(x0) and 'GRAD' in functions[1].cap(x0):
self.f1 = functions[0]
self.f2 = functions[1]
elif 'PROX' in functions[1].cap(x0) and 'GRAD' in functions[0].cap(x0):
self.f1 = functions[1]
self.f2 = functions[0]
else:
raise ValueError('Forward-backward requires a function to '
'implement prox() and the other grad().')
def _ista(self):
yn = self.sol - self.gamma * self.f2.grad(self.sol)
self.sol += self.lambda_ * (self.f1.prox(yn, self.gamma) - self.sol)
def _fista(self):
xn = self.un - self.gamma * self.f2.grad(self.un)
xn = self.f1.prox(xn, self.gamma)
tn1 = (1. + np.sqrt(1.+4.*self.tn**2.)) / 2.
self.un = xn + (self.tn-1) / tn1 * (xn-self.sol)
self.tn = tn1
self.sol = xn
[docs]class douglas_rachford(solver):
r"""
Douglas-Rachford proximal splitting algorithm.
This algorithm solves convex optimization problems composed of the sum of
two objective functions.
See generic attributes descriptions of the
:class:`pyunlocbox.solvers.solver` base class.
Parameters
----------
lambda_ : float, optional
The update term weight. It should be between 0 and 1. Default is 1.
Notes
-----
This algorithm requires the two functions to implement the
:meth:`pyunlocbox.functions.func.prox` method.
Examples
--------
>>> from pyunlocbox import functions, solvers
>>> import numpy as np
>>> y = [4, 5, 6, 7]
>>> x0 = np.zeros(len(y))
>>> f1 = functions.norm_l2(y=y)
>>> f2 = functions.dummy()
>>> solver = solvers.douglas_rachford(lambda_=1, gamma=1)
>>> ret = solvers.solve([f1, f2], x0, solver, atol=1e-5)
Solution found after 8 iterations :
objective function f(sol) = 2.927052e-06
last relative objective improvement : 8.000000e+00
stopping criterion : ATOL
>>> ret['sol']
array([ 3.99939034, 4.99923792, 5.99908551, 6.99893309])
"""
def __init__(self, lambda_=1, *args, **kwargs):
super(douglas_rachford, self).__init__(*args, **kwargs)
self.lambda_ = lambda_
def _pre(self, functions, x0):
if self.lambda_ < 0 or self.lambda_ > 1:
raise ValueError('Lambda is bounded by 0 and 1.')
if len(functions) != 2:
raise ValueError('Douglas-Rachford requires two convex functions.')
self.f1 = functions[0]
self.f2 = functions[1]
self.yn = np.array(x0)
self.sol = np.array(x0)
def _algo(self):
tmp = self.f1.prox(2 * self.sol - self.yn, self.gamma)
self.yn += self.lambda_ * (tmp - self.sol)
self.sol = self.f2.prox(self.yn, self.gamma)