Repository: lmfit/lmfit-py

Issue:
an aborted fit with `method='least_squares'` does not report correct fit results and statistics

#### Description
<!-- Provide a short description of the issue, describe the expected outcome, and give the actual result -->

As shown in #894, a fit with `method='least_squares'` that aborts, perhaps due to too many function evaluations, does not properly evaluate the residual and fit statistics with "last/best available parameter values".

Note that other methods do evaluate the residual and fit statistics with "last/best available parameter values" when the fit is aborted.

###### A Minimal, Complete, and Verifiable example
<!-- see, for example, https://stackoverflow.com/help/mcve on how to do this -->

see #894 


###### Link(s)
<!-- If you started a discussion on the lmfit mailing list, discussion page, or Stack Overflow, please provide the relevant link(s) -->

see #894 



Discussion:

Resolving patch:
diff --git a/lmfit/minimizer.py b/lmfit/minimizer.py
--- a/lmfit/minimizer.py
+++ b/lmfit/minimizer.py
@@ -964,9 +964,15 @@ def scalar_minimize(self, method='Nelder-Mead', params=None, max_nfev=None,
         params = result.params
 
         self.set_max_nfev(max_nfev, 2000*(result.nvarys+1))
+
         fmin_kws = dict(method=method, options={'maxiter': 2*self.max_nfev})
         if method == 'L-BFGS-B':
             fmin_kws['options']['maxfun'] = 2*self.max_nfev
+        elif method == 'COBYLA':
+            # for this method, we explicitly let the solver reach
+            # the users max nfev, and do not abort in _residual.
+            fmin_kws['options']['maxiter'] = self.max_nfev
+            self.max_nfev = 5*self.max_nfev
 
         # fmin_kws = dict(method=method, options={'maxfun': 2*self.max_nfev})
         fmin_kws.update(self.kws)
@@ -1600,7 +1606,8 @@ def least_squares(self, params=None, max_nfev=None, **kws):
                                 **least_squares_kws)
             result.residual = ret.fun
         except AbortFitException:
-            pass
+            ret = None
+            result.aborted = True
 
         # Note: scipy.optimize.least_squares is actually returning the
         # "last evaluation", which is not necessarily the "best result"; so we
@@ -1608,6 +1615,10 @@ def least_squares(self, params=None, max_nfev=None, **kws):
         if not result.aborted:
             result.nfev -= 1
             result.residual = self.__residual(ret.x, False)
+        elif result.nfev > self.max_nfev-5:
+            result.nfev -= 2
+            _best = result.last_internal_values
+            result.residual = self.__residual(_best, False)
         result._calculate_statistics()
 
         if not result.aborted:
@@ -1808,6 +1819,10 @@ def basinhopping(self, params=None, max_nfev=None, **kws):
             result.message = ret.message
             result.residual = self.__residual(ret.x)
             result.nfev -= 1
+        elif result.nfev > self.max_nfev-5:
+            result.nfev -= 2
+            _best = result.last_internal_values
+            result.residual = self.__residual(_best, False)
 
         result._calculate_statistics()
 
@@ -1987,7 +2002,10 @@ def brute(self, params=None, Ns=20, keep=50, workers=1, max_nfev=None):
             result.residual = self.__residual(result.brute_x0,
                                               apply_bounds_transformation=False)
             result.nfev = len(result.brute_Jout.ravel())
-
+        elif result.nfev > self.max_nfev-5:
+            result.nfev -= 2
+            _best = result.last_internal_values
+            result.residual = self.__residual(_best, False)
         result._calculate_statistics()
 
         return result
@@ -2110,6 +2128,10 @@ def ampgo(self, params=None, max_nfev=None, **kws):
 
             result.residual = self.__residual(result.ampgo_x0)
             result.nfev -= 1
+        elif result.nfev > self.max_nfev-5:
+            result.nfev -= 2
+            _best = result.last_internal_values
+            result.residual = self.__residual(_best, False)
 
         result._calculate_statistics()
 
@@ -2188,7 +2210,10 @@ def shgo(self, params=None, max_nfev=None, **kws):
 
             result.residual = self.__residual(result.shgo_x, False)
             result.nfev -= 1
-
+        elif result.nfev > self.max_nfev-5:
+            result.nfev -= 2
+            _best = result.last_internal_values
+            result.residual = self.__residual(_best, False)
         result._calculate_statistics()
 
         # calculate the cov_x and estimate uncertainties/correlations
@@ -2269,6 +2294,10 @@ def dual_annealing(self, params=None, max_nfev=None, **kws):
 
             result.residual = self.__residual(result.da_x, False)
             result.nfev -= 1
+        elif result.nfev > self.max_nfev-5:
+            result.nfev -= 2
+            _best = result.last_internal_values
+            result.residual = self.__residual(_best, False)
 
         result._calculate_statistics()
 


Test patch:
diff --git a/tests/test_minimizer.py b/tests/test_minimizer.py
--- a/tests/test_minimizer.py
+++ b/tests/test_minimizer.py
@@ -1,4 +1,8 @@
+import numpy as np
+import pytest
+
 from lmfit import Minimizer, Parameters
+from lmfit.models import GaussianModel
 
 
 def test_scalar_minimize_neg_value():
@@ -18,3 +22,41 @@ def objective(pars):
                                   options={'xatol': xtol, 'fatol': ftol})
     assert abs(result.params['x'].value - x0) < xtol
     assert abs(result.fun - fmin) < ftol
+
+
+@pytest.mark.parametrize('method', ('leastsq', 'least_squares', 'nelder',
+                                    'lbfgsb', 'powell', 'cg', 'bfgs', 'brute',
+                                    'dual_annealing', 'differential_evolution',
+                                    'ampgo', 'shgo', 'cobyla', 'basinhopping'))
+def test_aborted_solvers(method):
+    # github discussion #894
+    x = np.array([18.025, 18.075, 18.125, 18.175, 18.225, 18.275, 18.325, 18.375,
+                  18.425, 18.475, 18.525, 18.575, 18.625, 18.675, 18.725, 18.775,
+                  18.825, 18.875, 18.925, 18.975, 19.025, 19.075, 19.125, 19.175,
+                  19.225, 19.275, 19.325, 19.375, 19.425, 19.475, 19.525, 19.575,
+                  19.625, 19.675, 19.725, 19.775, 19.825, 19.875, 19.925, 19.975,
+                  20.025, 20.075, 20.125, 20.175, 20.225, 20.275, 20.325, 20.375,
+                  20.425, 20.475, 20.525, 20.575, 20.625, 20.675, 20.725, 20.775,
+                  20.825, 20.875, 20.925, 20.975])
+
+    y = np.array([43, 27, 14, 16, 20, 10, 15, 8, 3, 9, 2, 4, 8, 3, 3, 1, 2, 4,
+                  5, 0, 1, 3, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 23, 202, 824,
+                  344, 279, 276, 23, 2, 7, 8, 17, 22, 3, 25, 24, 17, 99, 288,
+                  172, 252, 103, 24, 20, 4, 1, 3, 1.0])
+
+    mod = GaussianModel(prefix='p1_') + GaussianModel(prefix='p2_')
+
+    pars = mod.make_params(p1_amplitude={'value': 70, 'min': 0, 'max': 200},
+                           p1_center={'value': 19.5, 'min': 8, 'max': 20.5},
+                           p1_sigma={'value': 0.05, 'min': 0, 'max': 1},
+                           p2_amplitude={'value': 40, 'min': 0, 'max': 100},
+                           p2_center={'value': 20.5, 'min': 19.5, 'max': 21.5},
+                           p2_sigma={'value': 0.05, 'min': 0, 'max': 1})
+
+    result = mod.fit(y, pars, x=x, max_nfev=40, method=method)
+    assert not result.success
+    assert not result.errorbars
+    assert result.redchi > 1000
+    assert result.redchi < 90000
+    assert result.nfev > 35
+    assert result.nfev < 45
diff --git a/tests/test_shgo.py b/tests/test_shgo.py
--- a/tests/test_shgo.py
+++ b/tests/test_shgo.py
@@ -28,13 +28,6 @@ def test_shgo_scipy_vs_lmfit():
     result_scipy = scipy.optimize.shgo(eggholder, bounds, n=30,
                                        sampling_method='sobol')
 
-    # in SciPy v1.7.0: "sobol was fixed and is now using scipy.stats.qmc.Sobol"
-    # FIXME: clean this up after we require SciPy >= 1.7.0
-    if int(scipy_version.split('.')[1]) < 7:
-        assert len(result_scipy.xl) == 13
-    else:
-        assert len(result_scipy.xl) == 6
-
     pars = lmfit.Parameters()
     pars.add_many(('x0', 0, True, -512, 512), ('x1', 0, True, -512, 512))
     mini = lmfit.Minimizer(eggholder_lmfit, pars)
@@ -53,13 +46,6 @@ def test_shgo_scipy_vs_lmfit_2():
     result_scipy = scipy.optimize.shgo(eggholder, bounds, n=60, iters=5,
                                        sampling_method='sobol')
 
-    # in SciPy v1.7.0: "sobol was fixed and is now using scipy.stats.qmc.Sobol"
-    # FIXME: clean this up after we require SciPy >= 1.7.0
-    if int(scipy_version.split('.')[1]) < 7:
-        assert len(result_scipy.xl) == 39
-    else:
-        assert len(result_scipy.xl) == 74
-
     pars = lmfit.Parameters()
     pars.add_many(('x0', 0, True, -512, 512), ('x1', 0, True, -512, 512))
     mini = lmfit.Minimizer(eggholder_lmfit, pars)
@@ -114,7 +100,7 @@ def test_shgo_bounds(minimizer_Alpine02):
     assert 2.5 <= out.params['x1'].value <= 7.5
 
 
-def test_shgo_disp_true(minimizer_Alpine02, capsys):
+def abandoned_test_shgo_disp_true(minimizer_Alpine02, capsys):
     """Test SHGO algorithm with disp is True."""
     kws = {'disp': True}
     minimizer_Alpine02.minimize(method='shgo', options=kws)