# solvers.py - Collection of least-squares solvers
# --------------------------------------------------
# This file is a part of DeerLab. License is MIT (see LICENSE.md).
# Copyright(c) 2019-2023: Luis Fabregas, Stefan Stoll and other contributors.
# External dependencies
import numpy as np
from scipy.optimize import least_squares, lsq_linear
# DeerLab dependencies
import deerlab as dl
from deerlab.classes import UQResult
from deerlab.fitresult import FitResult
from deerlab.utils import multistarts, hccm, parse_multidatasets, goodness_of_fit, Jacobian
import time
def timestamp():
# ===========================================================================================
# Get the seconds since epoch
secondsSinceEpoch = time.time()
# Convert seconds since epoch to struct_time
timeObj = time.localtime(secondsSinceEpoch)
# get the current timestamp elements from struct_time object i.e.
return '[%d-%d-%d %d:%d:%d]' % ( timeObj.tm_mday, timeObj.tm_mon, timeObj.tm_year, timeObj.tm_hour, timeObj.tm_min, timeObj.tm_sec)
# ===========================================================================================
# ===========================================================================================
def _check_bounds(lb,ub,par0=None,N=None):
"""
Boundary conditions parsing and validation
==========================================
Parses the input boundary conditions and determines whether they are correct.
"""
if par0 is not None:
N = len(par0)
if lb is None:
lb = np.full(N, -np.inf)
if ub is None:
ub = np.full(N, np.inf)
lb, ub = np.atleast_1d(lb, ub)
# Check that the boundaries are valid
if par0 is not None:
if len(lb) != len(par0) or len(ub) != len(par0):
raise TypeError('The lower/upper bounds and start values must have the same number of elements')
# Check that the non-linear start values are inside the box constraint
if np.any(par0 > ub) or np.any(par0 < lb):
raise ValueError('The start values are outside of the specified bounds.')
if N is not None:
if len(lb) != N or len(ub) != N:
raise TypeError(f'The lower/upper bounds and start values must have {N} elements')
if np.any(ub < lb):
raise ValueError('The upper bounds cannot be larger than the lower bounds.')
# Check if the nonlinear problem is bounded
isbounded = (not np.all(np.isinf(lb))) or (not np.all(np.isinf(ub)))
return lb,ub,isbounded
# ===========================================================================================
# ===========================================================================================
def _check_frozen(frozen,N=None,par0=None):
"""
Frozen parameters parsing and validation
========================================
Parses the input frozen conditions and determines whether they are correct.
"""
if par0 is not None:
N = len(par0)
if frozen is None:
frozen = np.full(N,False)
xfrozen = np.full(N,None)
else:
frozen = np.atleast_1d(frozen.copy())
xfrozen = frozen.copy()
frozen[np.where(frozen!=None)[0]] = True
frozen[np.where(frozen==None)[0]] = False
frozen = frozen.astype(bool)
return frozen,xfrozen
# ===========================================================================================
# ==============================================================================================
def _lsqcomponents(V, K, L=None, alpha=0, weights=None):
"""
Linear least-squares components
===============================
Calculate the components needed for the linear least-squares (LSQ) solvers.
"""
if weights is None:
weights = np.ones_like(V)
else:
weights = np.atleast_1d(weights)
# Compute components of the LSQ normal equations
Kw = weights[:,np.newaxis]*K
Vw = weights*V
KtK = Kw.T@Kw
KtV = Kw.T@Vw
# No regularization term -> done
if L is None:
return KtK, KtV
# Compute the regularization term
regterm = L.T@L
KtKreg = KtK + alpha**2*regterm
return KtKreg, KtV
# ==============================================================================================
# ===========================================================================================
def _prepare_linear_lsq(A,lb,ub,reg,L,tol,maxiter,nnlsSolver):
"""
Preparation of linear least-squares
===================================
Evaluates the conditions of the linear least-squares problem and determines:
- whether regularization is required
- the type of boundary constraints
- optimal linear least-squares solver to use
"""
Nlin = np.shape(A)[1]
# Determine whether to use regularization penalty
if reg == 'auto':
illConditioned = np.linalg.cond(A) > 10
includeRegularization = illConditioned
else:
includeRegularization = reg
# Check if the nonlinear and linear problems are constrained
linearConstrained = (not np.all(np.isinf(lb))) or (not np.all(np.isinf(ub)))
# Check for non-negativity constraints on the linear solution
nonNegativeOnly = (np.all(lb == 0)) and (np.all(np.isinf(ub)))
# Use an arbitrary axis
axis = np.arange(Nlin)
if L is None and includeRegularization:
d = np.minimum(2,len(axis))
L = dl.regoperator(axis,d)
# Prepare the linear solver
# ----------------------------------------------------------
if not linearConstrained:
# Unconstrained linear LSQ
linSolver = np.linalg.solve
parseResult = lambda result: result
elif linearConstrained and not nonNegativeOnly:
# Constrained linear LSQ
linSolver = lambda AtA, Aty: lsq_linear(AtA, Aty, bounds=(lb, ub), method='bvls')
parseResult = lambda result: result.x
elif linearConstrained and nonNegativeOnly:
# Non-negative linear LSQ
if nnlsSolver == 'fnnls':
linSolver = lambda AtA, Aty: fnnls(AtA, Aty, tol=tol, maxiter=maxiter)
elif nnlsSolver == 'cvx':
linSolver = lambda AtA, Aty: cvxnnls(AtA, Aty, tol=tol, maxiter=maxiter)
elif nnlsSolver == 'qp':
linSolver = lambda AtA, Aty: qpnnls(AtA, Aty)
parseResult = lambda result: result
# Ensure correct formatting and shield against float-point errors
validateResult = lambda result: np.maximum(lb,np.minimum(ub,np.atleast_1d(result)))
# ----------------------------------------------------------
return axis, L, linSolver, parseResult, validateResult, includeRegularization
# ===========================================================================================
# ===========================================================================================
def _model_evaluation(ymodels,parfit,paruq,uq,modeluq):
"""
Model evaluation
================
Evaluates the model(s) response(s) at the fitted solution and propagates the uncertainty in the fit
parameters to the model response.
"""
modelfit, modelfituq = [],[]
for ymodel in ymodels:
modelfit.append(ymodel(parfit))
if uq and modeluq:
modelfituq.append(paruq.propagate(ymodel))
else:
modelfituq.append(UQResult('void'))
return modelfit, modelfituq
# ===========================================================================================
# ===========================================================================================
def _goodness_of_fit_stats(ys,yfits,noiselvl,nParam,masks):
"""
Evaluation of goodness-of-fit statistics
========================================
Evaluates a series of statistical criteria for the goodness-of-fit of a list of dataset
and returns a list of dictionaries with the statistics for each dataset.
"""
stats = []
for y,yfit,sigma,mask in zip(ys,yfits,noiselvl,masks):
Ndof = len(y[mask]) - nParam
stats.append(goodness_of_fit(y[mask], yfit[mask], Ndof, sigma))
return stats
# ===========================================================================================
def _penalty_augmentation(alpha,L,P,type):
# ===========================================================================================
"""
LSQ residual and Jacobian augmentation
=======================================
Augments the residual and the Jacobian of a LSQ problem to include the
regularization penalty. The residual and Jacobian contributions of the
specific regularization methods are analytically introduced.
"""
# Compute the regularization penalty augmentation for the residual and the Jacobian
resreg = L@P
Jreg = L
# Include regularization parameter
resreg = alpha*resreg
Jreg = alpha*Jreg
if type=='residual':
return resreg
if type=='Jacobian':
return Jreg
# ===========================================================================================
# ===========================================================================================
def _unfrozen_subset(param,frozen,parfrozen):
param,frozen,parfrozen = np.atleast_1d(param,frozen,parfrozen)
param = param.tolist()
# Account for frozen parameters
param = np.atleast_1d([parfrozen[n] if frozen[n] else param.pop(0) for n in range(len(frozen))])
return param
# ===========================================================================================
# ===========================================================================================
def _unfrozen_subset_inv(param,frozen):
param,frozen = np.atleast_1d(param,frozen)
# Account for frozen parameters
return np.atleast_1d([param[n] for n in range(len(frozen)) if not frozen[n] ])
# ===========================================================================================
# ===========================================================================================
def _insertfrozen(parfit,parfrozen,frozen):
_parfit = parfrozen.copy()
_parfit[~frozen] = parfit
return _parfit.astype(float)
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
[docs]
def snlls(y, Amodel, par0=None, lb=None, ub=None, lbl=None, ubl=None, nnlsSolver='cvx', reg='auto', weights=None, verbose=0,
regparam='aic', regparamrange=None, multistart=1, regop=None, alphareopt=1e-3, extrapenalty=None, subsets=None,
ftol=1e-8, xtol=1e-8, max_nfev=1e8, lin_tol=1e-15, lin_maxiter=1e4, noiselvl=None, lin_frozen=None, mask=None,
nonlin_frozen=None, uq=True, modeluq=False):
r""" Separable non-linear least squares (SNLLS) solver
Fits a linear set of parameters `\theta_\mathrm{lin}` and non-linear parameters `\theta_\mathrm{nonlin}`
by solving the following general penalized separable non-linear least-squares problem:
.. math::
& \quad\quad \min_{\theta_\mathrm{nonlin}} \left\{ \Vert y - A(\theta_\mathrm{nonlin})\theta_\mathrm{lin}(\theta_\mathrm{nonlin}) \Vert^2 + \mathcal{P}(\theta_\mathrm{nonlin},\theta_\mathrm{lin}) \right\} \\
& \text{with} \\
& \quad\quad \theta_\mathrm{lin}(\theta_\mathrm{nonlin}) = {\arg\!\min}_{\theta} \left\{ \Vert y - A(\theta_\mathrm{nonlin})\theta \Vert^2 + \mathcal{R}(\theta) \right\} \\
& \text{subject to} \\
& \quad\quad \theta_\mathrm{lb} \leq \theta_\mathrm{nonlin} \leq \theta_\mathrm{ub} \quad\quad \theta_\mathrm{lbl} \leq \theta_\mathrm{lin} \leq \theta_\mathrm{ubl}
If the non-linear function `A(\theta_\mathrm{nonlin})` yields an ill-conditioned problem, the solver will include a regularization penalty `\mathcal{R}(\theta)` (Tikhonov by default)
with automated selection of the regularization parameter. Additional penalty terms `\mathcal{P}(\theta_\mathrm{nonlin},\theta_\mathrm{lin})` can be included if specified.
Parameters
----------
y : array_like or list of array_like
Input dataset(s) to be fitted.
Amodel : callable
Function taking an array of non-linear parameters and
returning a matrix array or a list thereof.
par0 : array_like
Start values of the non-linear parameters.
lb : array_like, optional
Lower bounds for the non-linear parameters, assumed unconstrained if not specified.
ub : array_like, optional
Upper bounds for the non-linear parameters, assumed unconstrained if not specified.
lbl : array_like, optional
Lower bounds for the linear parameters, assumed unconstrained if not specified.
ubl : array_like, optional
Upper bounds for the linear parameters, assumed unconstrained if not specified.
reg : boolean or string, optional
Determines the use of regularization on the solution of the linear problem.
* ``'auto'`` - Automatic decision based con the condition number of the non-linear model ``Amodel``.
* ``True`` - Forces regularization regardless of the condition number
* ``False`` - Disables regularization regardless of the condition number
The default is ``'auto'``.
regparam : string or float scalar, optional
Method for the automatic selection of the optimal regularization parameter:
* ``'lr'`` - L-curve minimum-radius method (LR)
* ``'lc'`` - L-curve maximum-curvature method (LC)
* ``'cv'`` - Cross validation (CV)
* ``'gcv'`` - Generalized Cross Validation (GCV)
* ``'rgcv'`` - Robust Generalized Cross Validation (rGCV)
* ``'srgcv'`` - Strong Robust Generalized Cross Validation (srGCV)
* ``'aic'`` - Akaike information criterion (AIC)
* ``'bic'`` - Bayesian information criterion (BIC)
* ``'aicc'`` - Corrected Akaike information criterion (AICC)
* ``'rm'`` - Residual method (RM)
* ``'ee'`` - Extrapolated Error (EE)
* ``'ncp'`` - Normalized Cumulative Periodogram (NCP)
* ``'gml'`` - Generalized Maximum Likelihood (GML)
* ``'mcl'`` - Mallows' C_L (MCL)
The regularization parameter can be manually specified by passing a scalar value
instead of a string. The default ``'aic'``.
regparamrange : array_like, optional
Search range for the optimization of the regularization parameter. Must be specified as a list ``[regparam_lb,regparam_ub]``
with the lower/upper boundaries of the regularization parameter.
regop : 2D array_like, optional
Regularization operator matrix, the default is the second-order differential operator.
extrapenalty: callable or list thereof, optional
Custom penalty function(s) to impose upon the solution. A single penalty must be specified as a callable function.
Multiple penalties can be specified as a list of callable functons. Each function must take two inputs, a vector of non-linear parameters
and a vector of linear parameters, and return a vector to be added to the residual vector (``pen = fcn(pnonlin,plin)``).
The square of the penalty is computed internally.
alphareopt : float scalar, optional
Relative parameter change threshold for reoptimizing the regularization parameter
when using a selection method, the default is ``1e-3``.
nnlsSolver : string, optional
Solver used to solve a non-negative least-squares problem (if applicable):
* ``'qp'`` - Optimization of the NNLS problem using the ``quadprog`` package. Only Python <= 3.10.
* ``'cvx'`` - Optimization of the NNLS problem using the ``cvxopt`` package.
* ``'fnnls'`` - Optimization using the fast NNLS algorithm.
The default is ``'cvx'``.
noiselvl : array_like, optional
Noise standard deviation of the input signal(s), if not specified it is estimated automatically.
mask : arrau_like or list thereof, optional
Array (or list of arrays) containing boolean (True/False) values defining a mask over one or multiple datasets.
All dataset point with enabled mask values (True) will be acoounted for during the fitting procedure. All disabled
values (False) will not be inlcuded. This does not affect model evaluation and uncertainty propagation.
weights : array_like, optional
Array of weighting coefficients for the individual signals in global fitting.
If not specified all datasets are weighted inversely proportional to their noise levels.
multistart : int scalar, optional
Number of starting points for global optimization, the default is ``1``.
xtol : float scalar, optional
Tolerance for termination by the change of the independent variables. Default is 1e-8. The optimization process is stopped when ``norm(dx) < xtol * (xtol + norm(x))``.
If set to ``None``, the termination by this condition is disabled.
ftol : float scalar, optional
Tolerance for termination by the change of the cost function. Default is 1e-8. The optimization process is stopped when ``dF < ftol*F``,
and there was an adequate agreement between a local quadratic model and the true model in the last step.
If set to ``None``, the termination by this condition is disabled.
max_nfev : float scalar, optional
Maximum number of function evaluations before the termination. the default is ``1e8``.
lin_maxiter : float scalar, optional
Linear solver maximal number of iterations, the default is ``1e4``.
lin_tol : float scalar, optional
Linear solver tolerance to decide when a value is defined as a zero, the default is ``1e-15``.
nonlin_frozen : array_like
Values for non-linear parameters to be frozen during the optimization. If set to ``None`` a non-linear parameter
will be optimized, if set to a numerical value it will be fixed to it and ignored during the optimization.
lin_frozen : array_like
Values for linear parameters to be frozen during the optimization. If set to ``None`` a linear parameter
will be optimized, if set to a numerical value it will be fixed to it and ignored during the optimization.
uq : boolean, optional
Enable/disable the uncertainty quantification analysis. Enabled by default.
modeluq : boolean, optional
Enable/disable the propagation of the parameter uncertainties to the model's response (can be computationally costly). Disabled by default
subsets : array_like, optional
Vector of dataset subset indices, if the datasets are passed concatenated instead of a list.
verbose : scalar integer, optional
Level of verbosity during the analysis:
* ``0`` : Work silently (default).
* ``1`` : Display progress including the non-linear least-squares' solver termination report.
* ``2`` : Display progress including the non-linear least-squares' solver iteration details.
.. caution::
The verbose output from the non-linear least-squares solver uses a different definition of the cost function than DeerLab.
DeerLab uses the sum of squares of the residuals divided by the number of data points, whereas the non-linear least-squares solver uses the sum of squares of the residuals divided by 2.
Returns
-------
:ref:`FitResult` with the following fields defined:
nonlin : ndarray
Fitted non-linear parameters.
lin : ndarray
Fitted linear parameters.
param : ndarray
Fitted full parameter set.
model : ndarray
Fitted model.
nonlinUncert : :ref:`UQResult`
Uncertainty quantification of the non-linear parameter set.
linUncert : :ref:`UQResult`
Uncertainty quantification of the linear parameter set.
paramUncert : :ref:`UQResult`
Uncertainty quantification of the full parameter set.
modelUncert : :ref:`UQResult`
Uncertainty quantification of the fitted model. Only computed if ``modeluq=True``.
regparam : scalar
Regularization parameter value used for the regularization of the linear parameters.
stats : dict
Goodness of fit statistical estimators
* ``stats['chi2red']`` - Reduced \chi^2 test
* ``stats['r2']`` - R^2 test
* ``stats['rmsd']`` - Root-mean squared deviation (RMSD)
* ``stats['aic']`` - Akaike information criterion
* ``stats['aicc']`` - Corrected Akaike information criterion
* ``stats['bic']`` - Bayesian information criterion
* ``stats['autocorr']`` - Autocorrelation based on Durbin–Watson statistic
success : bool
Whether or not the optimizer exited successfully.
cost : float
Value of the cost function at the solution.
residuals : ndarray
Vector of residuals at the solution.
noiselvl : ndarray
Estimated or user-given noise standard deviations.
"""
if verbose>0:
print(f'{timestamp()} Preparing the SNLLS analysis...')
# Ensure that all arrays are numpy.nparray
par0 = np.atleast_1d(par0)
# Parse multiple datsets and non-linear operators into a single concatenated vector/matrix
y, Amodel, weights, mask, subsets, noiselvl = parse_multidatasets(y, Amodel, weights, noiselvl, masks=mask, subsets=subsets)
if not callable(Amodel):
Amatrix = Amodel.copy()
Amodel = lambda _: Amatrix
par0 = np.array([])
if regparamrange is None:
regparamrange = [1e-8,1e3]
# Get info on the problem parameters and non-linear operator
A0 = Amodel(par0)
if len(np.shape(A0))!=2:
Amodel_ = Amodel
Amodel = lambda p: np.atleast_2d(Amodel_(p)).T
A0 = Amodel(par0)
if np.shape(A0)[0]!=np.shape(y)[0]:
raise RuntimeError(f"The number of elements ({np.shape(A0)[0]}) in the model's output does not match the number of elements ({np.shape(y)[0]}) in the data.")
complexy = np.iscomplexobj(y)
imagsubsets = []
if complexy:
# If the data is complex-valued
for subset in subsets:
imagsubsets.append(subset + len(y))
y = np.concatenate([y.real,y.imag])
weights = np.concatenate([weights,weights])
mask = np.concatenate([mask,mask])
Amodel__ = Amodel
if np.iscomplexobj(A0):
# If the design matrix is complex-valued
Amodel = lambda p: np.concatenate([Amodel__(p).real,Amodel__(p).imag])
A0 = np.concatenate([A0.real,A0.imag])
A0 = Amodel(par0)
if not complexy:
# If the data is not complex-valued
y = np.concatenate([y,np.zeros_like(y)])
weights = np.concatenate([weights,weights])
mask = np.concatenate([mask,mask])
elif complexy:
# If the design matrix is not complex-valued, but the data is
Amodel = lambda p: np.concatenate([Amodel__(p),np.zeros_like(A0)])
Nnonlin = len(par0)
Nlin = np.shape(A0)[1]
includeExtrapenalty = extrapenalty is not None
if includeExtrapenalty:
extrapenalty = np.atleast_1d(extrapenalty)
for penalty in extrapenalty:
if not callable(penalty):
raise TypeError("The keyword argument 'extrapenalty' must be a callable function or a list thereof.")
# Checks for bounds constraints
# ----------------------------------------------------------
lb,ub,nonLinearBounded = _check_bounds(lb,ub,par0=par0)
lbl,ubl,linearBounded = _check_bounds(lbl,ubl,N=Nlin)
nonlin_frozen,nonlin_parfrozen = _check_frozen(nonlin_frozen,par0=par0)
lin_frozen,lin_parfrozen = _check_frozen(lin_frozen,N=Nlin)
Nlin_notfrozen = Nlin - np.sum(lin_frozen)
Nnonlin_notfrozen = Nnonlin - np.sum(nonlin_frozen)
lb_red,ub_red,par0_red = [var[~nonlin_frozen] for var in [lb,ub,par0]]
lbl_red,ubl_red = [var[~lin_frozen] for var in [lbl,ubl]]
A0red = A0[:,~lin_frozen]
# Redefine model to take just the unfrozen parameter subset
_Amodel = Amodel
Amodel = lambda param: _Amodel(_unfrozen_subset(param,nonlin_frozen,nonlin_parfrozen))
if includeExtrapenalty:
extrapenalty_ = [penalty for penalty in extrapenalty]
extrapenalty = [lambda pnonlin, plin: penalty(_unfrozen_subset(pnonlin,nonlin_frozen,nonlin_parfrozen), plin) for penalty in extrapenalty_]
# Prepare the optimal solver setup for the linear problem
if Nlin_notfrozen>0:
_, L, linSolver, parseResult, validateResult, includeRegularization = _prepare_linear_lsq(A0red,lbl_red,ubl_red,reg,regop,lin_tol,lin_maxiter,nnlsSolver)
else:
includeRegularization = False
# Pre-allocate nonlocal variables
check = False
regparam_prev = 0
par_prev = [0]*len(par0_red)
alpha = None
xfit = np.zeros(Nlin)
Ndof_lin = 0
if verbose>0:
print(f'{timestamp()} Preparations completed.')
def linear_problem(y,A,optimize_alpha,alpha):
#===========================================================================
"""
Linear problem
------------------
Solves the linear subproblem of the SNLLS objective function via linear LSQ
constrained, unconstrained, or regularized.
"""
# If all linear parameters are frozen, do not optimize
if Nlin_notfrozen==0:
return lin_parfrozen.astype(float), None, 0
# Remove columns corresponding to frozen linear parameters
Ared = A[:,~lin_frozen]
# Frozen component of the model response
yfrozen = (A[:,lin_frozen]@lin_parfrozen[lin_frozen]).astype(float)
# Optimiza the regularization parameter only if needed
if optimize_alpha:
linsolver_result = lambda AtA, Aty: parseResult(linSolver(AtA, Aty))
output = dl.selregparam((y-yfrozen)[mask], Ared[mask,:], linsolver_result, regparam,
weights=weights[mask], regop=L, noiselvl=noiselvl,
searchrange=regparamrange,full_output=True)
alpha = output[0]
alpha_stats['alphas_evaled'] = output[1]
alpha_stats['functional'] = output[2]
alpha_stats['residuals'] = output[3]
alpha_stats['penalties'] = output[4]
# Components for linear least-squares
AtA, Aty = _lsqcomponents((y-yfrozen)[mask], Ared[mask,:], L, alpha, weights=weights[mask])
Ndof = np.maximum(0,np.trace(Ared@np.linalg.pinv(AtA)))
# Solve the linear least-squares problem
result = linSolver(AtA, Aty)
result = parseResult(result)
xfit = validateResult(result)
# Insert back the frozen linear parameters
xfit = _insertfrozen(xfit,lin_parfrozen,lin_frozen)
return xfit, alpha, Ndof
#===========================================================================
def ResidualsFcn(p):
#===========================================================================
"""
Residuals function
------------------
Provides vector of residuals, which is the objective function for the
non-linear least-squares solver.
"""
nonlocal par_prev, check, regparam_prev, xfit, alpha, alpha_stats, Ndof, Ndof_lin
# Non-linear model evaluation
A = Amodel(p)
# Check whether optimization of the regularization parameter is needed
if includeRegularization :
if type(regparam) is str:
if Nnonlin_notfrozen>0:
# If the parameter vector has not changed by much...
if check and all(abs(par_prev-p)/(p+np.finfo(np.float64).eps) < alphareopt):
# ...use the alpha optimized in the previous iteration
optimize_alpha = False
alpha = regparam_prev
else:
# ...otherwise optimize with current settings
alpha = regparam
optimize_alpha = True
check = True
else:
alpha = regparam
optimize_alpha = True
else:
# Fixed regularization parameter
alpha = regparam
optimize_alpha = False
# Store current iteration data for next one
par_prev = p
regparam_prev = alpha
else:
# Non-linear operator without penalty
optimize_alpha = False
alpha = 0
xfit,alpha,Ndof_lin = linear_problem(y,A,optimize_alpha,alpha)
regparam_prev = alpha
# Compute residual vector
res = weights*(Amodel(p)@xfit - y)
# Apply mask to residual
res = res[mask]
# Compute residual from user-defined penalties
if includeExtrapenalty:
for penalty in extrapenalty:
penres = penalty(p,xfit)
penres = np.atleast_1d(penres)
res = np.concatenate((res,penres))
if includeRegularization:
# Augmented residual
res_reg = _penalty_augmentation(alpha, L, xfit,'residual')
res = np.concatenate((res,res_reg))
return res
#===========================================================================
alpha_stats = {'alphas_evaled':[],'functional':[],'residuals':[],'penalties':[]}
# -------------------------------------------------------------------
# Only linear parameters
# -------------------------------------------------------------------
if Nnonlin_notfrozen==0 and Nlin_notfrozen>0:
if verbose>0:
print(f'{timestamp()} Linear least-squares routine in progress...')
if type(regparam) is str and includeRegularization:
# Optimized regularization parameter
alpha = regparam
optimize_alpha = True
else:
# Fixed regularization parameter
alpha = regparam
optimize_alpha = False
# Solve the linear LSQ problem
linfit, alpha, Ndof_lin = linear_problem(y,Amodel(None),optimize_alpha,alpha)
# Compute the residual
res = ResidualsFcn(None)
fvals = np.sum(res**2)
nonlinfit = None
# -------------------------------------------------------------------
# With non-linear parameters
# -------------------------------------------------------------------
elif Nnonlin_notfrozen>0:
if verbose>0:
print(f'{timestamp()} Non-linear least-squares routine in progress...')
# Preprare multiple start global optimization if requested
if multistart > 1 and not nonLinearBounded:
raise TypeError('Multistart optimization cannot be used with unconstrained non-linear parameters.')
multiStartPar0 = multistarts(multistart, par0_red, lb_red, ub_red)
# Pre-allocate containers for multi-start run
fvals, nonlinfits, linfits, sols = [],[],[],[]
# Multi-start global optimization
for par0 in multiStartPar0:
# Run the non-linear solver
sol = least_squares(ResidualsFcn, par0, bounds=(lb_red, ub_red), max_nfev=int(max_nfev), xtol=xtol, ftol=ftol, verbose=verbose)
nonlinfits.append(sol.x)
linfits.append(xfit)
fvals.append(2*sol.cost) # least_squares uses 0.5*sum(residual**2)
sols.append(sol)
# Find global minimum from multiple runs
globmin = np.argmin(fvals)
linfit = linfits[globmin]
nonlinfit = nonlinfits[globmin]
fvals = np.min(fvals)
# Insert frozen parameters back into the nonlinear parameter vector
if nonlinfit is not None:
par_prev = nonlinfit.copy()
nonlinfit = _insertfrozen(nonlinfit,nonlin_parfrozen,nonlin_frozen)
# Compute the fit residual
_ResidualsFcn = lambda nonlinfit: ResidualsFcn(_unfrozen_subset_inv(nonlinfit,nonlin_frozen))
res = _ResidualsFcn(nonlinfit)
fvals = np.sum(res**2)/len(res)
if verbose>0:
print(f'{timestamp()} Least-squares routine finished.')
# Uncertainty analysis
#---------------------
if uq:
if verbose>0:
print(f'{timestamp()} Uncertainty analysis in progress...')
# Define the indices of the parameter subsets
nonlin_subset = np.arange(0,Nnonlin)
lin_subset = np.arange(Nnonlin,Nnonlin+Nlin)
def uq_subset(uq_full,subset,subset_lb,subset_ub):
#-----------------------------------------------------------------------------
"Get the uncertainty quantification for a subset of parameters"
subset_model = lambda x: x[subset]
uq_subset = uq_full.propagate(subset_model,lb=subset_lb, ub=subset_ub)
return uq_subset
#-----------------------------------------------------------------------------
# Jacobian (non-linear part)
Jnonlin = Jacobian(_ResidualsFcn,nonlinfit,lb,ub)
# Jacobian (linear part)
scale = np.trapz(linfit,np.arange(Nlin))
Jlin = (weights[:,np.newaxis]*Amodel(nonlinfit))[mask,:]
if includeExtrapenalty:
for penalty in extrapenalty:
Jlin = np.concatenate((Jlin, Jacobian(lambda plin: penalty(nonlinfit,plin),linfit,lbl,ubl)))
if includeRegularization:
Jlin = np.concatenate((Jlin, _penalty_augmentation(alpha, L, linfit,'Jacobian')))
Jlin *= scale
# Full Jacobian
J = np.concatenate((Jnonlin,Jlin),axis=1)
# Calculate the heteroscedasticity consistent covariance matrix
covmatrix = hccm(J, res)
# Get combined parameter sets and boundaries
parfit = np.concatenate((nonlinfit, linfit))
lbs = np.concatenate((lb, lbl))
ubs = np.concatenate((ub, ubl))
# Account for scale of linear parameters since covariance matrix is scale invariant
covmatrix[np.ix_(lin_subset,lin_subset)] *= scale**2
# Set rows/columns corresponding to frozen parameters to zero
frozen = np.concatenate([nonlin_frozen,lin_frozen])
covmatrix[frozen,:] = 0
covmatrix[:,frozen] = 0
# Construct the uncertainty quantification object
paramuq = UQResult('moment', parfit, covmatrix, lbs, ubs)
# Split the uncertainty quantification of nonlinear/linear parts
paramuq_nonlin = uq_subset(paramuq,nonlin_subset,lb,ub)
paramuq_lin = uq_subset(paramuq,lin_subset,lbl,ubl)
else:
paramuq_nonlin = UQResult('void')
paramuq_lin = UQResult('void')
paramuq = UQResult('void')
if verbose>0:
print(f'{timestamp()} Uncertainty analysis completed.')
print(f'{timestamp()} Model evaluation in progress...')
# Get fitted signals and their uncertainty
parfit = np.concatenate((nonlinfit, linfit))
nonlin_idx = np.arange(len(nonlinfit))
lin_idx = np.arange(len(nonlinfit),len(parfit))
Amodel = _Amodel # Use the model with the full parameter set
def ymodel(n):
return lambda p: (Amodel(p[nonlin_idx])@p[lin_idx])[subsets[n]]
if complexy:
ymodel_ = ymodel
def ymodel(n):
return lambda p: ymodel_(n)(p) + 1j*(Amodel(p[nonlin_idx])@p[lin_idx])[imagsubsets[n]]
ymodels = [ymodel(n) for n in range(len(subsets))]
modelfit,modelfituq = _model_evaluation(ymodels,parfit,paramuq,uq,modeluq)
# Make lists of data and fits
ys = [y[subset] for subset in subsets]
masks = [mask[subset] for subset in subsets]
if complexy:
ys = [ys[n] + 1j*y[imagsubset] for n,imagsubset in enumerate(imagsubsets)]
yfits = modelfit.copy()
yuq = modelfituq.copy()
if verbose>0:
print(f'{timestamp()} Model evaluation completed.')
# Goodness-of-fit
# ---------------
Ndof = Nnonlin + Ndof_lin
stats = _goodness_of_fit_stats(ys,yfits,noiselvl,Ndof,masks)
# Prepare the plot function inputs
plot_inputs=[ys,yfits,yuq,noiselvl,masks]
if len(stats) == 1:
stats = stats[0]
modelfit = modelfit[0]
modelfituq = modelfituq[0]
# return FitResult(y=ys, mask=mask, nonlin=nonlinfit, lin=linfit, param=parfit, model=modelfit, nonlinUncert=paramuq_nonlin,
# linUncert=paramuq_lin, paramUncert=paramuq, modelUncert=modelfituq, regparam=alpha, plot=plotfcn,
# stats=stats, cost=fvals, residuals=res, noiselvl=noiselvl, regparam_stats=alpha_stats)
return FitResult(y=ys, mask=mask, nonlin=nonlinfit, lin=linfit, param=parfit, model=modelfit, nonlinUncert=paramuq_nonlin,
linUncert=paramuq_lin, paramUncert=paramuq, modelUncert=modelfituq, regparam=alpha,
stats=stats, cost=fvals, residuals=res, noiselvl=noiselvl, regparam_stats=alpha_stats, __plot_inputs=plot_inputs)
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
[docs]
def fnnls(AtA, Atb, tol=None, maxiter=None, verbose=False):
#=====================================================================================
r"""
Fast non-negative least-squares (NNLS) solver.
Solves the problem
.. math:: \min \Vert b - Ax \Vert^2
where ``AtA`` `= A^TA` and ``Atb`` `= A^Tb` using the fast
non-negative least-squares (FNNLS) algorithm [1]_, [2]_.
Parameters
----------
AtA : matrix_like
Matrix `A^TA`
Atb : array_like
Vector `A^Tb`
tol : float scalar
Tolerance used for deciding when elements of x are less than zero.
The default is ``tol = max(shape(AtA))*norm(AtA,1)*eps``.
maxiter : integer scalar
Maximum number of iterations. The default is ``maxiter = 5*max(shape(AtA))``
verbose : boolean
If set to ``True``, display the iteration details during the optimization.
Returns
-------
x : ndarray
Solution vector.
References
----------
.. [1] R. Bro, S. De Jong,
A Fast Non-Negativity-Constrained Least Squares Algorithm
Journal of Chemometrics 11 (1997) 393-401
.. [2] Lawson and Hanson,
Solving Least Squares Problems
Prentice-Hall, 1974.
"""
unsolvable = False
count = 0
# Use all-zero starting vector
N = np.shape(AtA)[1]
x = np.zeros(N)
# Calculate tolerance and maxiter if not given.
if tol is None:
eps = np.finfo(float).eps
tol = 10*eps*np.linalg.norm(AtA,1)*max(np.shape(AtA))
if maxiter is None:
maxiter = 5*N
passive = x>0 # initial positive/passive set (points where constraint is not active)
x[~passive] = 0
w = Atb - AtA @ x # negative gradient of error functional 0.5*||A*x-y||^2
# Outer loop: Add variables to positive set if w indicates that fit can be improved.
outIteration = 0
maxIterations = 5*N
while np.any(w>tol) and np.any(~passive):
outIteration += 1
# Add the most promising variable (with largest w) to positive set.
t = np.argmax(w)
passive[t] = True
# Solve unconstrained problem for new augmented positive set.
# This gives a candidate solution with potentially new negative variables.
x_ =np.zeros(N)
if np.sum(passive)==1:
x_[passive] = Atb[passive]/AtA[passive,passive]
else:
x_[passive] = np.linalg.solve(AtA[np.ix_(passive,passive)],Atb[passive])
# Inner loop: Iteratively eliminate negative variables from candidate solution.
iIteration = 0
while any((x_<=tol) & passive) and iIteration<maxIterations:
iIteration += 1
# Calculate maximum feasible step size and do step.
negative = (x_<=tol) & passive
alpha = min(x[negative]/(x[negative]-x_[negative]))
x += alpha*(x_-x)
# Remove all negative variables from positive set.
passive[x<tol] = False
# Solve unconstrained problem for reduced positive set.
x_ = np.zeros(N)
if np.sum(passive)==1:
x_[passive] = Atb[passive]/AtA[passive,passive]
else:
x_[passive] = np.linalg.solve(AtA[np.ix_(passive,passive)],Atb[passive])
# Accept non-negative candidate solution and calculate w.
if all(x == x_):
count += 1
else:
count = 0
if count > 5:
unsolvable = True
break
x = x_
w = Atb - AtA@x
w[passive] = -np.inf
if verbose:
print(f'{outIteration:10.0f}{iIteration:15.0f}{max(w):20.4e}\n')
if verbose:
if unsolvable:
print('Optimization stopped because the solution cannot be further changed. \n')
elif any(~passive):
print('Optimization stopped because the active set has been completely emptied. \n')
elif w>tol:
print(f'Optimization stopped because the gradient (w) is inferior than the tolerance value TolFun = {tol:.6e}. \n')
else:
print('Solution found. \n')
return x
#=====================================================================================
[docs]
def cvxnnls(AtA, Atb, tol=None, maxiter=None,x0=None):
#=====================================================================================
r"""
Non-negative least-squares (NNLS) via the CVXOPT package.
Solves the problem
.. math:: \min \Vert b - Ax \Vert^2
where ``AtA`` `= A^TA` and ``Atb`` `= A^Tb` using the non-linear convex programming
tools from CVXOPT [1]_, [2]_.
Parameters
----------
AtA : matrix_like
Matrix `A^TA`
Atb : array_like
Vector `A^Tb`
tol : float scalar
Tolerance used for deciding when elements of x are less than zero.
The default is ``tol = max(shape(AtA))*norm(AtA,1)*eps``.
maxiter : integer scalar
Maximum number of iterations. The default is ``maxiter = 5*max(shape(AtA))``
verbose : boolean
If set to ``True``, display the iteration details during the optimization.
Returns
-------
x : ndarray
Solution vector.
References
----------
.. [1] M. Andersen, J. Dahl, and L. Vandenberghen,
CVXOPT, Python Software for Convex Optimization
http://cvxopt.org/
.. [2] Rein, Lewe, Andrade, Kacprzak and Weber
J. Magn. Reson., 295 (2018) 17–26.
Global analysis of complex PELDOR time traces
"""
import cvxopt as cvx
N = np.shape(AtA)[1]
if tol is None:
eps = np.finfo(float).eps
tol = 10*eps*np.linalg.norm(AtA,1)*max(np.shape(AtA))
if maxiter is None:
maxiter = 5*N
if x0 is None:
x0 = np.zeros(N)
cAtA = cvx.matrix(AtA)
cAtb = -cvx.matrix(Atb)
lb = cvx.matrix(np.zeros(N))
I = -cvx.matrix(np.eye(N, N))
# Set optimization stop criteria
cvx.solvers.options['show_progress'] = False
cvx.solvers.options['max_iters'] = maxiter
cvx.solvers.options['abstol'] = tol
cvx.solvers.options['reltol'] = tol
try:
P = cvx.solvers.qp(cAtA, cAtb, I, lb, initvals=cvx.matrix(x0))['x']
P = np.squeeze(np.asarray(P))
except:
P = x0
return P
#=====================================================================================
#=====================================================================================
def qpnnls(AtA, Atb):
r"""
Non-negative least-squares (NNLS) via the quadprog package.
Solves the problem
.. math:: \min \Vert b - Ax \Vert^2
where ``AtA`` `= A^TA` and ``Atb`` `= A^Tb`.
This routine uses the the Goldfarb/Idnani dual algorithm [1]_.
Parameters
----------
AtA : matrix_like
Matrix `A^TA`
Atb : array_like
Vector `A^Tb`
Returns
-------
x : ndarray
Solution vector.
References
----------
... [1] D. Goldfarb and A. Idnani (1983). A numerically stable dual
method for solving strictly convex quadratic programs.
Mathematical Programming, 27, 1-33.
"""
try:
from quadprog import solve_qp
except ModuleNotFoundError:
raise ModuleNotFoundError(
'The quadprog package is required for this function.'+
'Install it with "pip install quadprog".')
N = np.shape(AtA)[1]
I = np.eye(N)
lb = np.zeros(N)
meq = 0
try:
# Solve the quadratic program
x = solve_qp(AtA, Atb, I, lb, meq)[0]
except:
try:
# If AtA matrix is not positive definite, find nearest one
x = solve_qp(dl.utils.nearest_psd(AtA), Atb, I, lb, meq)[0]
except:
try:
# For very rare cases
x = cvxnnls(AtA, Atb)
except:
x = lb
return x
#=====================================================================================