-
Notifications
You must be signed in to change notification settings - Fork 280
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix issues related to providing a Jacobian function (#973)
* Updated the Minimizer.least_squares method to wrap the Jacobian function - The Jacobian function provided by the user is now handled similarly to how it is handled in, e.g., Minimizer.leastsq. - If the Jacobian function is called 'Dfun' in the keyword arguments, then it is also accepted (Minimizer.scalar_minimize already does this). * Updated the Minimizer.__jacobian method signature to fix exception - The signature now matches the signature of Minimizer.__residual. This was changed because the following exception was being raised whenever the user provided a function for calculating the Jacobian: "TypeError: Minimizer.__jacobian() got an unexpected keyword argument 'apply_bounds_transformation'". * Updated the Minimizer.__jacobian method to use the new parameter - The updating of the parameter values is now similar to how it is done in Minimizer.__residual. * Updated test_least_squares_jacobian_types to handle the wrapped Jacobian function - The params argument of the jac_array function is now an actual Parameters object like it is in the f function, which means that the parameters can be accessed via string keys. * Updated the coerce_float64 function - Added explicit support for sparse matrices (e.g., Block Sparse Row) and LinearOperator objects. This change is needed because the updated test_least_squares_jacobian_types would otherwise fail. Similar to how these types are handled when calculating the Hessian matrix in Minimizer.least_squares. * Renamed the Minimizer.__jacobian method to avoid a bug related to pickling - Attempting to make use of multiprocessing.Pool while providing, e.g., Minimizer.least_squares a function for calculating the Jacobian did not work prior to the renaming of the method due to an issue related to pickling and unpickling of private methods. See the (currently) open issue (python/cpython#77188) and pull request (python/cpython#21480) for more information. * Added a test for when the "least_squares" method is provided a function for the Jacobian - Verifies that a) the fitted parameters have the expected values, b) the jac function is actually called, and c) that there are indeed fewer function evaluations as a result. * Updated the docstring of Minimizer._jacobian - The docstring is updated to include the parameters and return values. * Added tests for (un)pickling Minimizer when a Jacobian function is provided * Updated timeout value in test_jacobian_with_forkingpickler - Increased the timeout value so that slow runners can actually succeed instead of timing out. If no timeout was specified, then a failing test could end up hanging instead. * Added an example that demonstrates the benefits of providing a Jacobian function * Updated the list of contributors
- Loading branch information
Showing
5 changed files
with
388 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
""" | ||
Benchmarks of methods with and without computing the Jacobian analytically | ||
========================================================================== | ||
Providing a function that calculates the Jacobian matrix analytically can | ||
reduce the time spent finding a solution. The results from benchmarks comparing | ||
two methods (``leastsq`` and ``least_squares``) with and without a function to | ||
calculate the Jacobian matrix analytically are presented below. | ||
First we define the model function, the residual function, and the appropriate | ||
Jacobian functions: | ||
""" | ||
from timeit import timeit | ||
from types import SimpleNamespace | ||
|
||
import matplotlib.pyplot as plt | ||
import numpy as np | ||
|
||
from lmfit import Parameters, minimize | ||
|
||
NUM_JACOBIAN_CALLS = 0 | ||
|
||
|
||
def func(var, x): | ||
return var[0] * np.exp(-var[1]*x) + var[2] | ||
|
||
|
||
def residual(pars, x, data): | ||
a, b, c = pars['a'], pars['b'], pars['c'] | ||
model = func((a, b, c), x) | ||
return model - data | ||
|
||
|
||
def dfunc(pars, x, data): | ||
global NUM_JACOBIAN_CALLS | ||
NUM_JACOBIAN_CALLS += 1 | ||
|
||
a, b = pars['a'], pars['b'] | ||
v = np.exp(-b*x) | ||
return np.array([v, -a*x*v, np.ones(len(x))]) | ||
|
||
|
||
def jacfunc(pars, x, data): | ||
global NUM_JACOBIAN_CALLS | ||
NUM_JACOBIAN_CALLS += 1 | ||
|
||
a, b = pars['a'], pars['b'] | ||
v = np.exp(-b*x) | ||
jac = np.ones((len(x), 3), dtype=np.float64) | ||
jac[:, 0] = v | ||
jac[:, 1] = -a * x * v | ||
return jac | ||
|
||
|
||
a, b, c = 2.5, 1.3, 0.8 | ||
|
||
x = np.linspace(0, 4, 50) | ||
y = func([a, b, c], x) | ||
|
||
data = y + 0.15*np.random.RandomState(seed=2021).normal(size=x.size) | ||
|
||
|
||
############################################################################### | ||
# Then we define the different cases to benchmark (i.e., different methods with | ||
# and without a function to calculate the Jacobian analytically) and the number | ||
# of repetitions per case: | ||
cases = ( | ||
dict( | ||
method='leastsq', | ||
), | ||
dict( | ||
method='leastsq', | ||
Dfun=dfunc, | ||
col_deriv=1, | ||
), | ||
dict( | ||
method='least_squares', | ||
), | ||
dict( | ||
method='least_squares', | ||
jac=jacfunc, | ||
), | ||
) | ||
|
||
num_repeats = 100 | ||
results = [] | ||
|
||
for kwargs in cases: | ||
params = Parameters() | ||
params.add('a', value=10) | ||
params.add('b', value=10) | ||
params.add('c', value=10) | ||
|
||
wrapper = lambda: minimize( | ||
residual, | ||
params, | ||
args=(x,), | ||
kws={'data': data}, | ||
**kwargs, | ||
) | ||
time = timeit(wrapper, number=num_repeats) / num_repeats | ||
|
||
NUM_JACOBIAN_CALLS = 0 | ||
fit = wrapper() | ||
|
||
results.append(SimpleNamespace( | ||
time=time, | ||
num_jacobian_calls=NUM_JACOBIAN_CALLS, | ||
fit=fit, | ||
kwargs=kwargs, | ||
)) | ||
|
||
|
||
############################################################################### | ||
# Finally, we present the results: | ||
labels = [] | ||
|
||
for result in results: | ||
label = result.kwargs['method'] | ||
if result.num_jacobian_calls > 0: | ||
label += ' with Jac.' | ||
|
||
labels.append(label) | ||
|
||
label_width = max(map(len, labels)) | ||
lines = [ | ||
'| ' | ||
+ ' | '.join([ | ||
'Method'.ljust(label_width), | ||
'Avg. time (ms)', | ||
'# func. (+ Jac.) calls', | ||
'Chi-squared', | ||
'a'.ljust(5), | ||
'b'.ljust(5), | ||
'c'.ljust(6), | ||
]) | ||
+ '|' | ||
] | ||
|
||
print(f'The "true" parameters are: a = {a:.3f}, b = {b:.3f}, c = {c:.3f}\n') | ||
fig, ax = plt.subplots() | ||
ax.plot(x, data, marker='.', linestyle='none', label='data') | ||
|
||
for (result, label) in zip(results, labels): | ||
linestyle = '-' | ||
if result.num_jacobian_calls > 0: | ||
linestyle = '--' | ||
|
||
a = result.fit.params['a'].value | ||
b = result.fit.params['b'].value | ||
c = result.fit.params['c'].value | ||
y = func([a, b, c], x) | ||
ax.plot(x, y, label=label, alpha=0.5, linestyle=linestyle) | ||
|
||
columns = [ | ||
label.ljust(label_width), | ||
f'{result.time * 1000:.2f}'.ljust(14), | ||
( | ||
f'{result.fit.nfev}' | ||
+ ( | ||
f' (+{result.num_jacobian_calls})' | ||
if result.num_jacobian_calls > 0 else | ||
'' | ||
) | ||
).ljust(22), | ||
f'{result.fit.chisqr:.3f}'.ljust(11), | ||
f'{a:.3f}'.ljust(5, '0'), | ||
f'{b:.3f}'.ljust(5, '0'), | ||
f'{c:.3f}'.ljust(5, '0'), | ||
] | ||
lines.append('| ' + ' | '.join(columns) + ' |') | ||
|
||
lines.insert(1, '|-' + '-|-'.join('-' * len(col) for col in columns) + '-|') | ||
print('\n'.join(lines)) | ||
|
||
ax.set_xlabel('x') | ||
ax.set_ylabel('y') | ||
ax.legend() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.