Repository: scipy/scipy

Issue:
curve_fit, analytic Jacobian and weights
`curve_fit` accepts generic kwargs, and sends them directly to `leastsq`. This way one can, in principle, supply an analytic Jacobian for the fit function. However, if also uncertainties are supplied, `sigma is not None`, the Jacobian needs to be scaled, https://github.com/scipy/scipy/blob/v0.15.1/scipy/optimize/minpack.py#L450

The fix is similar to https://github.com/lmfit/lmfit-py/pull/234 and https://github.com/jjhelmus/leastsqbound-scipy/pull/7.

(found by @nmayorov).



Discussion:
@ev-br I would like to work on this, if this is still open.

go for it

It might take me quite a amount of time before I solve this as I don't understand a lot of things. If its on priority or someone else wants to work on it, please create a PR.
Thank you!


Resolving patch:
diff --git a/scipy/optimize/minpack.py b/scipy/optimize/minpack.py
--- a/scipy/optimize/minpack.py
+++ b/scipy/optimize/minpack.py
@@ -449,12 +449,24 @@ def leastsq(func, x0, args=(), Dfun=None, full_output=0,
         return (retval[0], info)
 
 
-def _general_function(params, xdata, ydata, function):
-    return function(xdata, *params) - ydata
+def _wrap_func(func, xdata, ydata, weights):
+    if weights is None:
+        def func_wrapped(params):
+            return func(xdata, *params) - ydata
+    else:
+        def func_wrapped(params):
+            return weights * (func(xdata, *params) - ydata)
+    return func_wrapped
 
 
-def _weighted_general_function(params, xdata, ydata, function, weights):
-    return weights * (function(xdata, *params) - ydata)
+def _wrap_jac(jac, xdata, weights):
+    if weights is None:
+        def jac_wrapped(params):
+            return jac(xdata, *params)
+    else:
+        def jac_wrapped(params):
+            return weights[:, np.newaxis] * np.asarray(jac(xdata, *params))
+    return jac_wrapped
 
 
 def _initialize_feasible(lb, ub):
@@ -476,7 +488,7 @@ def _initialize_feasible(lb, ub):
 
 def curve_fit(f, xdata, ydata, p0=None, sigma=None, absolute_sigma=False,
               check_finite=True, bounds=(-np.inf, np.inf), method=None,
-              **kwargs):
+              jac=None, **kwargs):
     """
     Use non-linear least squares to fit a function, f, to data.
 
@@ -534,6 +546,15 @@ def curve_fit(f, xdata, ydata, p0=None, sigma=None, absolute_sigma=False,
         case.
 
         .. versionadded:: 0.17
+    jac : callable, string or None, optional
+        Function with signature ``jac(x, ...)`` which computes the Jacobian
+        matrix of the model function with respect to parameters as a dense
+        array_like structure. It will be scaled according to provided `sigma`.
+        If None (default), the Jacobian will be estimated numerically.
+        String keywords for 'trf' and 'dogbox' methods can be used to select
+        a finite difference scheme, see `least_squares`.
+
+        .. versionadded:: 0.18
     kwargs
         Keyword arguments passed to `leastsq` for ``method='lm'`` or
         `least_squares` otherwise.
@@ -642,23 +663,23 @@ def curve_fit(f, xdata, ydata, p0=None, sigma=None, absolute_sigma=False,
         else:
             xdata = np.asarray(xdata)
 
-    args = (xdata, ydata, f)
-    if sigma is None:
-        func = _general_function
-    else:
-        func = _weighted_general_function
-        args += (1.0 / asarray(sigma),)
+    weights = 1.0 / asarray(sigma) if sigma is not None else None
+    func = _wrap_func(f, xdata, ydata, weights)
+    if callable(jac):
+        jac = _wrap_jac(jac, xdata, weights)
+    elif jac is None and method != 'lm':
+        jac = '2-point'
 
     if method == 'lm':
         # Remove full_output from kwargs, otherwise we're passing it in twice.
         return_full = kwargs.pop('full_output', False)
-        res = leastsq(func, p0, args=args, full_output=1, **kwargs)
+        res = leastsq(func, p0, Dfun=jac, full_output=1, **kwargs)
         popt, pcov, infodict, errmsg, ier = res
         cost = np.sum(infodict['fvec'] ** 2)
         if ier not in [1, 2, 3, 4]:
             raise RuntimeError("Optimal parameters not found: " + errmsg)
     else:
-        res = least_squares(func, p0, args=args, bounds=bounds, method=method,
+        res = least_squares(func, p0, jac=jac, bounds=bounds, method=method,
                             **kwargs)
 
         if not res.success:


Test patch:
diff --git a/scipy/optimize/tests/test_minpack.py b/scipy/optimize/tests/test_minpack.py
--- a/scipy/optimize/tests/test_minpack.py
+++ b/scipy/optimize/tests/test_minpack.py
@@ -470,6 +470,42 @@ def f(x, a):
             # If the initial guess is ignored, then popt_2 would be close 0.
             assert_allclose(popt_1, popt_2)
 
+    def test_jac(self):
+        # Test that Jacobian callable is handled correctly and
+        # weighted if sigma is provided.
+        def f(x, a, b):
+            return a * np.exp(-b*x)
+
+        def jac(x, a, b):
+            e = np.exp(-b*x)
+            return np.vstack((e, -a * x * e)).T
+
+        xdata = np.linspace(0, 1, 11)
+        ydata = f(xdata, 2., 2.)
+
+        # Test numerical options for least_squares backend.
+        for method in ['trf', 'dogbox']:
+            for scheme in ['2-point', '3-point', 'cs']:
+                popt, pcov = curve_fit(f, xdata, ydata, jac=scheme,
+                                       method=method)
+                assert_allclose(popt, [2, 2])
+
+        # Test the analytic option.
+        for method in ['lm', 'trf', 'dogbox']:
+            popt, pcov = curve_fit(f, xdata, ydata, method=method, jac=jac)
+            assert_allclose(popt, [2, 2])
+
+        # Now add an outlier and provide sigma.
+        ydata[5] = 100
+        sigma = np.ones(xdata.shape[0])
+        sigma[5] = 200
+        for method in ['lm', 'trf', 'dogbox']:
+            popt, pcov = curve_fit(f, xdata, ydata, sigma=sigma, method=method,
+                                   jac=jac)
+            # Still the optimization process is influenced somehow,
+            # have to set rtol=1e-3.
+            assert_allclose(popt, [2, 2], rtol=1e-3)
+
 
 class TestFixedPoint(TestCase):