pyerrors.fits

  1import gc
  2import warnings
  3from collections.abc import Sequence
  4
  5import autograd.numpy as anp
  6import iminuit
  7import matplotlib.pyplot as plt
  8import numpy as np
  9import scipy.optimize
 10import scipy.stats
 11from autograd import elementwise_grad as egrad
 12from autograd import hessian as auto_hessian
 13from autograd import jacobian as auto_jacobian
 14from matplotlib import gridspec
 15from numdifftools import Hessian as num_hessian
 16from numdifftools import Jacobian as num_jacobian
 17from odrpack import odr_fit
 18
 19from .obs import Obs, cov_Obs, covariance, derived_observable, invert_corr_cov_cholesky
 20
 21
 22class Fit_result(Sequence):
 23    """Represents fit results.
 24
 25    Attributes
 26    ----------
 27    fit_parameters : list
 28        results for the individual fit parameters,
 29        also accessible via indices.
 30    chisquare_by_dof : float
 31        reduced chisquare.
 32    p_value : float
 33        p-value of the fit
 34    t2_p_value : float
 35        Hotelling t-squared p-value for correlated fits.
 36    """
 37
 38    def __init__(self):
 39        self.fit_parameters = None
 40
 41    def __getitem__(self, idx):
 42        return self.fit_parameters[idx]
 43
 44    def __len__(self):
 45        return len(self.fit_parameters)
 46
 47    def gamma_method(self, **kwargs):
 48        """Apply the gamma method to all fit parameters"""
 49        [o.gamma_method(**kwargs) for o in self.fit_parameters]
 50
 51    gm = gamma_method
 52
 53    def __str__(self):
 54        my_str = 'Goodness of fit:\n'
 55        if hasattr(self, 'chisquare_by_dof'):
 56            my_str += '\u03C7\u00b2/d.o.f. = ' + f'{self.chisquare_by_dof:2.6f}' + '\n'
 57        elif hasattr(self, 'residual_variance'):
 58            my_str += 'residual variance = ' + f'{self.residual_variance:2.6f}' + '\n'
 59        if hasattr(self, 'chisquare_by_expected_chisquare'):
 60            my_str += '\u03C7\u00b2/\u03C7\u00b2exp  = ' + f'{self.chisquare_by_expected_chisquare:2.6f}' + '\n'
 61        if hasattr(self, 'p_value'):
 62            my_str += 'p-value   = ' + f'{self.p_value:2.4f}' + '\n'
 63        if hasattr(self, 't2_p_value'):
 64            my_str += 't\u00B2p-value = ' + f'{self.t2_p_value:2.4f}' + '\n'
 65        my_str += 'Fit parameters:\n'
 66        for i_par, par in enumerate(self.fit_parameters):
 67            my_str += str(i_par) + '\t' + ' ' * int(par >= 0) + str(par).rjust(int(par < 0.0)) + '\n'
 68        return my_str
 69
 70    def __repr__(self):
 71        m = max(map(len, list(self.__dict__.keys()))) + 1
 72        return '\n'.join([key.rjust(m) + ': ' + repr(value) for key, value in sorted(self.__dict__.items())])
 73
 74
 75def least_squares(x, y, func, priors=None, silent=False, **kwargs):
 76    r'''Performs a non-linear fit to y = func(x).
 77        ```
 78
 79    Parameters
 80    ----------
 81    For an uncombined fit:
 82
 83    x : list
 84        list of floats.
 85    y : list
 86        list of Obs.
 87    func : object
 88        fit function, has to be of the form
 89
 90        ```python
 91        import autograd.numpy as anp
 92
 93        def func(a, x):
 94            return a[0] + a[1] * x + a[2] * anp.sinh(x)
 95        ```
 96
 97        For multiple x values func can be of the form
 98
 99        ```python
100        def func(a, x):
101            (x1, x2) = x
102            return a[0] * x1 ** 2 + a[1] * x2
103        ```
104        It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation
105        will not work.
106
107    OR For a combined fit:
108
109    x : dict
110        dict of lists.
111    y : dict
112        dict of lists of Obs.
113    funcs : dict
114        dict of objects
115        fit functions have to be of the form (here a[0] is the common fit parameter)
116        ```python
117        import autograd.numpy as anp
118        funcs = {"a": func_a,
119                "b": func_b}
120
121        def func_a(a, x):
122            return a[1] * anp.exp(-a[0] * x)
123
124        def func_b(a, x):
125            return a[2] * anp.exp(-a[0] * x)
126
127        It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation
128        will not work.
129
130    priors : dict or list, optional
131        priors can either be a dictionary with integer keys and the corresponding priors as values or
132        a list with an entry for every parameter in the fit. The entries can either be
133        Obs (e.g. results from a previous fit) or strings containing a value and an error formatted like
134        0.548(23), 500(40) or 0.5(0.4)
135    silent : bool, optional
136        If True all output to the console is omitted (default False).
137    initial_guess : list
138        can provide an initial guess for the input parameters. Relevant for
139        non-linear fits with many parameters. In case of correlated fits the guess is used to perform
140        an uncorrelated fit which then serves as guess for the correlated fit.
141    method : str, optional
142        can be used to choose an alternative method for the minimization of chisquare.
143        The possible methods are the ones which can be used for scipy.optimize.minimize and
144        migrad of iminuit. If no method is specified, Levenberg–Marquardt is used.
145        Reliable alternatives are migrad, Powell and Nelder-Mead.
146    tol: float, optional
147        can be used (only for combined fits and methods other than Levenberg–Marquardt) to set the tolerance for convergence
148        to a different value to either speed up convergence at the cost of a larger error on the fitted parameters (and possibly
149        invalid estimates for parameter uncertainties) or smaller values to get more accurate parameter values
150        The stopping criterion depends on the method, e.g. migrad: edm_max = 0.002 * tol * errordef (EDM criterion: edm < edm_max)
151    correlated_fit : bool
152        If True, use the full inverse covariance matrix in the definition of the chisquare cost function.
153        For details about how the covariance matrix is estimated see `pyerrors.obs.covariance`.
154        In practice the correlation matrix is Cholesky decomposed and inverted (instead of the covariance matrix).
155        This procedure should be numerically more stable as the correlation matrix is typically better conditioned (Jacobi preconditioning).
156    inv_chol_cov_matrix [array,list], optional
157        array: shape = (number of y values) X (number of y values)
158        list:   for an uncombined fit: [""]
159                for a combined fit: list of keys belonging to the corr_matrix saved in the array, must be the same as the keys of the y dict in alphabetical order
160        If correlated_fit=True is set as well, can provide an inverse covariance matrix (y errors, dy_f included!) of your own choosing for a correlated fit.
161        The matrix must be a lower triangular matrix constructed from a Cholesky decomposition: The function invert_corr_cov_cholesky(corr, inverrdiag) can be
162        used to construct it from a correlation matrix (corr) and the errors dy_f of the data points (inverrdiag = np.diag(1 / np.asarray(dy_f))). For the correct
163        ordering the correlation matrix (corr) can be sorted via the function sort_corr(corr, kl, yd) where kl is the list of keys and yd the y dict.
164    expected_chisquare : bool
165        If True estimates the expected chisquare which is
166        corrected by effects caused by correlated input data (default False).
167    resplot : bool
168        If True, a plot which displays fit, data and residuals is generated (default False).
169    qqplot : bool
170        If True, a quantile-quantile plot of the fit result is generated (default False).
171    num_grad : bool
172        Use numerical differentation instead of automatic differentiation to perform the error propagation (default False).
173    n_parms : int, optional
174        Number of fit parameters. Overrides automatic detection of parameter count.
175        Useful when autodetection fails. Must match the length of initial_guess or priors (if provided).
176
177    Returns
178    -------
179    output : Fit_result
180        Parameters and information on the fitted result.
181    Examples
182    ------
183    >>> # Example of a correlated (correlated_fit = True, inv_chol_cov_matrix handed over) combined fit, based on a randomly generated data set
184    >>> import numpy as np
185    >>> from scipy.stats import norm
186    >>> from scipy.linalg import cholesky
187    >>> import pyerrors as pe
188    >>> # generating the random data set
189    >>> num_samples = 400
190    >>> N = 3
191    >>> x = np.arange(N)
192    >>> x1 = norm.rvs(size=(N, num_samples)) # generate random numbers
193    >>> x2 = norm.rvs(size=(N, num_samples)) # generate random numbers
194    >>> r = r1 = r2 = np.zeros((N, N))
195    >>> y = {}
196    >>> for i in range(N):
197    >>>    for j in range(N):
198    >>>        r[i, j] = np.exp(-0.8 * np.fabs(i - j)) # element in correlation matrix
199    >>> errl = np.sqrt([3.4, 2.5, 3.6]) # set y errors
200    >>> for i in range(N):
201    >>>    for j in range(N):
202    >>>        r[i, j] *= errl[i] * errl[j] # element in covariance matrix
203    >>> c = cholesky(r, lower=True)
204    >>> y = {'a': np.dot(c, x1), 'b': np.dot(c, x2)} # generate y data with the covariance matrix defined
205    >>> # random data set has been generated, now the dictionaries and the inverse covariance matrix to be handed over are built
206    >>> x_dict = {}
207    >>> y_dict = {}
208    >>> chol_inv_dict = {}
209    >>> data = []
210    >>> for key in y.keys():
211    >>>    x_dict[key] = x
212    >>>    for i in range(N):
213    >>>        data.append(pe.Obs([[i + 1 + o for o in y[key][i]]], ['ens'])) # generate y Obs from the y data
214    >>>    [o.gamma_method() for o in data]
215    >>>    corr = pe.covariance(data, correlation=True)
216    >>>    inverrdiag = np.diag(1 / np.asarray([o.dvalue for o in data]))
217    >>>    chol_inv = pe.obs.invert_corr_cov_cholesky(corr, inverrdiag) # gives form of the inverse covariance matrix needed for the combined correlated fit below
218    >>> y_dict = {'a': data[:3], 'b': data[3:]}
219    >>> # common fit parameter p[0] in combined fit
220    >>> def fit1(p, x):
221    >>>    return p[0] + p[1] * x
222    >>> def fit2(p, x):
223    >>>    return p[0] + p[2] * x
224    >>> fitf_dict = {'a': fit1, 'b':fit2}
225    >>> fitp_inv_cov_combined_fit = pe.least_squares(x_dict,y_dict, fitf_dict, correlated_fit = True, inv_chol_cov_matrix = [chol_inv,['a','b']])
226    Fit with 3 parameters
227    Method: Levenberg-Marquardt
228    `ftol` termination condition is satisfied.
229    chisquare/d.o.f.: 0.5388013574561786 # random
230    fit parameters [1.11897846 0.96361162 0.92325319] # random
231
232    '''
233    output = Fit_result()
234
235    if (isinstance(x, dict) and isinstance(y, dict) and isinstance(func, dict)):
236        xd = {key: anp.asarray(x[key]) for key in x}
237        yd = y
238        funcd = func
239        output.fit_function = func
240    elif (isinstance(x, dict) or isinstance(y, dict) or isinstance(func, dict)):
241        raise TypeError("All arguments have to be dictionaries in order to perform a combined fit.")
242    else:
243        x = np.asarray(x)
244        xd = {"": x}
245        yd = {"": y}
246        funcd = {"": func}
247        output.fit_function = func
248
249    if kwargs.get('num_grad') is True:
250        jacobian = num_jacobian
251        hessian = num_hessian
252    else:
253        jacobian = auto_jacobian
254        hessian = auto_hessian
255
256    key_ls = sorted(list(xd.keys()))
257
258    if sorted(list(yd.keys())) != key_ls:
259        raise ValueError('x and y dictionaries do not contain the same keys.')
260
261    if sorted(list(funcd.keys())) != key_ls:
262        raise ValueError('x and func dictionaries do not contain the same keys.')
263
264    x_all = np.concatenate([np.array(xd[key]).transpose() for key in key_ls]).transpose()
265    y_all = np.concatenate([np.array(yd[key]) for key in key_ls])
266
267    y_f = [o.value for o in y_all]
268    dy_f = [o.dvalue for o in y_all]
269
270    if len(x_all.shape) > 2:
271        raise ValueError("Unknown format for x values")
272
273    if np.any(np.asarray(dy_f) <= 0.0):
274        raise Exception("No y errors available, run the gamma method first.")
275
276    # number of fit parameters
277    if 'n_parms' in kwargs:
278        n_parms = kwargs.get('n_parms')
279        if not isinstance(n_parms, int):
280            raise TypeError(
281                f"'n_parms' must be an integer, got {n_parms!r} "
282                f"of type {type(n_parms).__name__}."
283            )
284        if n_parms <= 0:
285            raise ValueError(
286                f"'n_parms' must be a positive integer, got {n_parms}."
287            )
288    else:
289        n_parms_ls = []
290        for key in key_ls:
291            if not callable(funcd[key]):
292                raise TypeError('func (key=' + key + ') is not a function.')
293            if np.asarray(xd[key]).shape[-1] != len(yd[key]):
294                raise ValueError('x and y input (key=' + key + ') do not have the same length')
295            for n_loc in range(100):
296                try:
297                    funcd[key](np.arange(n_loc), x_all.T[0])
298                except TypeError:
299                    continue
300                except IndexError:
301                    continue
302                else:
303                    break
304            else:
305                raise RuntimeError("Fit function (key=" + key + ") is not valid.")
306            n_parms_ls.append(n_loc)
307
308        n_parms = max(n_parms_ls)
309
310    if len(key_ls) > 1:
311        for key in key_ls:
312            if np.asarray(yd[key]).shape != funcd[key](np.arange(n_parms), xd[key]).shape:
313                raise ValueError(f"Fit function {key} returns the wrong shape ({funcd[key](np.arange(n_parms), xd[key]).shape} instead of {np.asarray(yd[key]).shape})\nIf the fit function is just a constant you could try adding x*0 to get the correct shape.")
314
315    if not silent:
316        print('Fit with', n_parms, 'parameter' + 's' * (n_parms > 1))
317
318    if priors is not None:
319        if isinstance(priors, (list, np.ndarray)):
320            if n_parms != len(priors):
321                raise ValueError("'priors' does not have the correct length.")
322
323            loc_priors = []
324            for i_n, i_prior in enumerate(priors):
325                loc_priors.append(_construct_prior_obs(i_prior, i_n))
326
327            prior_mask = np.arange(len(priors))
328            output.priors = loc_priors
329
330        elif isinstance(priors, dict):
331            loc_priors = []
332            prior_mask = []
333            output.priors = {}
334            for pos, prior in priors.items():
335                if isinstance(pos, int):
336                    prior_mask.append(pos)
337                else:
338                    raise TypeError("Prior position needs to be an integer.")
339                loc_priors.append(_construct_prior_obs(prior, pos))
340
341                output.priors[pos] = loc_priors[-1]
342            if max(prior_mask) >= n_parms:
343                raise ValueError("Prior position out of range.")
344        else:
345            raise TypeError("Unkown type for `priors`.")
346
347        p_f = [o.value for o in loc_priors]
348        dp_f = [o.dvalue for o in loc_priors]
349        if np.any(np.asarray(dp_f) <= 0.0):
350            raise Exception("No prior errors available, run the gamma method first.")
351    else:
352        p_f = dp_f = np.array([])
353        prior_mask = []
354        loc_priors = []
355
356    if 'initial_guess' in kwargs:
357        x0 = kwargs.get('initial_guess')
358        if len(x0) != n_parms:
359            raise ValueError(f'Initial guess does not have the correct length: {len(x0)} vs. {n_parms}')
360    else:
361        x0 = [0.1] * n_parms
362
363    if priors is None:
364        def general_chisqfunc_uncorr(p, ivars, pr):
365            model = anp.concatenate([anp.array(funcd[key](p, xd[key])).reshape(-1) for key in key_ls])
366            return (ivars - model) / dy_f
367    else:
368        def general_chisqfunc_uncorr(p, ivars, pr):
369            model = anp.concatenate([anp.array(funcd[key](p, xd[key])).reshape(-1) for key in key_ls])
370            return anp.concatenate(((ivars - model) / dy_f, (p[prior_mask] - pr) / dp_f))
371
372    def chisqfunc_uncorr(p):
373        return anp.sum(general_chisqfunc_uncorr(p, y_f, p_f) ** 2)
374
375    if kwargs.get('correlated_fit') is True:
376        if 'inv_chol_cov_matrix' in kwargs:
377            chol_inv = kwargs.get('inv_chol_cov_matrix')
378            if (chol_inv[0].shape[0] != len(dy_f)):
379                raise TypeError('The number of columns of the inverse covariance matrix handed over needs to be equal to the number of y errors.')
380            if (chol_inv[0].shape[0] != chol_inv[0].shape[1]):
381                raise TypeError('The inverse covariance matrix handed over needs to have the same number of rows as columns.')
382            if (chol_inv[1] != key_ls):
383                raise ValueError('The keys of inverse covariance matrix are not the same or do not appear in the same order as the x and y values.')
384            chol_inv = chol_inv[0]
385            if np.any(np.diag(chol_inv) <= 0) or (not np.all(chol_inv == np.tril(chol_inv))):
386                raise ValueError('The inverse covariance matrix inv_chol_cov_matrix[0] has to be a lower triangular matrix constructed from a Cholesky decomposition.')
387        else:
388            corr = covariance(y_all, correlation=True, **kwargs)
389            inverrdiag = np.diag(1 / np.asarray(dy_f))
390            chol_inv = invert_corr_cov_cholesky(corr, inverrdiag)
391
392        def general_chisqfunc(p, ivars, pr):
393            model = anp.concatenate([anp.array(funcd[key](p, xd[key])).reshape(-1) for key in key_ls])
394            return anp.concatenate((anp.dot(chol_inv, (ivars - model)), (p[prior_mask] - pr) / dp_f))
395
396        def chisqfunc(p):
397            return anp.sum(general_chisqfunc(p, y_f, p_f) ** 2)
398    else:
399        general_chisqfunc = general_chisqfunc_uncorr
400        chisqfunc = chisqfunc_uncorr
401
402    output.method = kwargs.get('method', 'Levenberg-Marquardt')
403    if not silent:
404        print('Method:', output.method)
405
406    if output.method != 'Levenberg-Marquardt':
407        if output.method == 'migrad':
408            tolerance = 1e-4  # default value of 1e-1 set by iminuit can be problematic
409            if 'tol' in kwargs:
410                tolerance = kwargs.get('tol')
411            fit_result = iminuit.minimize(chisqfunc_uncorr, x0, tol=tolerance)  # Stopping criterion 0.002 * tol * errordef
412            if kwargs.get('correlated_fit') is True:
413                fit_result = iminuit.minimize(chisqfunc, fit_result.x, tol=tolerance)
414            output.iterations = fit_result.nfev
415        else:
416            tolerance = 1e-12
417            if 'tol' in kwargs:
418                tolerance = kwargs.get('tol')
419            fit_result = scipy.optimize.minimize(chisqfunc_uncorr, x0, method=kwargs.get('method'), tol=tolerance)
420            if kwargs.get('correlated_fit') is True:
421                fit_result = scipy.optimize.minimize(chisqfunc, fit_result.x, method=kwargs.get('method'), tol=tolerance)
422            output.iterations = fit_result.nit
423
424        chisquare = fit_result.fun
425
426    else:
427        if 'tol' in kwargs:
428            print('tol cannot be set for Levenberg-Marquardt')
429
430        def chisqfunc_residuals_uncorr(p):
431            return general_chisqfunc_uncorr(p, y_f, p_f)
432
433        fit_result = scipy.optimize.least_squares(chisqfunc_residuals_uncorr, x0, method='lm', ftol=1e-15, gtol=1e-15, xtol=1e-15)
434        if kwargs.get('correlated_fit') is True:
435            def chisqfunc_residuals(p):
436                return general_chisqfunc(p, y_f, p_f)
437
438            fit_result = scipy.optimize.least_squares(chisqfunc_residuals, fit_result.x, method='lm', ftol=1e-15, gtol=1e-15, xtol=1e-15)
439
440        chisquare = np.sum(fit_result.fun ** 2)
441        assert np.isclose(chisquare, chisqfunc(fit_result.x), atol=1e-14)
442
443        output.iterations = fit_result.nfev
444
445    if not fit_result.success:
446        raise Exception('The minimization procedure did not converge.')
447
448    output.chisquare = chisquare
449    output.dof = y_all.shape[-1] - n_parms + len(loc_priors)
450    output.p_value = 1 - scipy.stats.chi2.cdf(output.chisquare, output.dof)
451    if output.dof > 0:
452        output.chisquare_by_dof = output.chisquare / output.dof
453    else:
454        output.chisquare_by_dof = float('nan')
455
456    output.message = fit_result.message
457    if not silent:
458        print(fit_result.message)
459        print('chisquare/d.o.f.:', output.chisquare_by_dof)
460        print('fit parameters', fit_result.x)
461
462    def prepare_hat_matrix():
463        hat_vector = []
464        for key in key_ls:
465            if (len(xd[key]) != 0):
466                hat_vector.append(jacobian(funcd[key])(fit_result.x, xd[key]))
467        hat_vector = [item for sublist in hat_vector for item in sublist]
468        return hat_vector
469
470    if kwargs.get('expected_chisquare') is True:
471        if kwargs.get('correlated_fit') is not True:
472            W = np.diag(1 / np.asarray(dy_f))
473            cov = covariance(y_all)
474            hat_vector = prepare_hat_matrix()
475            A = W @ hat_vector
476            P_phi = A @ np.linalg.pinv(A.T @ A) @ A.T
477            expected_chisquare = np.trace((np.identity(y_all.shape[-1]) - P_phi) @ W @ cov @ W) + len(loc_priors)
478            output.chisquare_by_expected_chisquare = output.chisquare / expected_chisquare
479            if not silent:
480                print('chisquare/expected_chisquare:', output.chisquare_by_expected_chisquare)
481
482    fitp = fit_result.x
483
484    try:
485        hess = hessian(chisqfunc)(fitp)
486    except (TypeError, ValueError, np.linalg.LinAlgError):
487        raise Exception("It is required to use autograd.numpy instead of numpy within fit functions, see the documentation for details.") from None
488
489    len_y = len(y_f)
490
491    def chisqfunc_compact(d):
492        return anp.sum(general_chisqfunc(d[:n_parms], d[n_parms: n_parms + len_y], d[n_parms + len_y:]) ** 2)
493
494    jac_jac_y = hessian(chisqfunc_compact)(np.concatenate((fitp, y_f, p_f)))
495
496    # Compute hess^{-1} @ jac_jac_y[:n_parms + m, n_parms + m:] using LAPACK dgesv
497    try:
498        deriv_y = -scipy.linalg.solve(hess, jac_jac_y[:n_parms, n_parms:])
499    except np.linalg.LinAlgError as err:
500        raise Exception("Cannot invert hessian matrix.") from err
501
502    result = []
503    for i in range(n_parms):
504        result.append(derived_observable(lambda x_all, i=i, **kwargs: (x_all[0] + np.finfo(np.float64).eps) / (y_all[0].value + np.finfo(np.float64).eps) * fitp[i], list(y_all) + loc_priors, man_grad=list(deriv_y[i])))
505
506    output.fit_parameters = result
507
508    # Hotelling t-squared p-value for correlated fits.
509    if kwargs.get('correlated_fit') is True:
510        n_cov = np.min(np.vectorize(lambda x_all: x_all.N)(y_all))
511        output.t2_p_value = 1 - scipy.stats.f.cdf((n_cov - output.dof) / (output.dof * (n_cov - 1)) * output.chisquare,
512                                                  output.dof, n_cov - output.dof)
513
514    if kwargs.get('resplot') is True:
515        for key in key_ls:
516            residual_plot(xd[key], yd[key], funcd[key], result, title=key)
517
518    if kwargs.get('qqplot') is True:
519        for key in key_ls:
520            qqplot(xd[key], yd[key], funcd[key], result, title=key)
521
522    return output
523
524
525def total_least_squares(x, y, func, silent=False, **kwargs):
526    r'''Performs a non-linear fit to y = func(x) and returns a list of Obs corresponding to the fit parameters.
527
528    Parameters
529    ----------
530    x : list
531        list of Obs, or a tuple of lists of Obs
532    y : list
533        list of Obs. The dvalues of the Obs are used as x- and yerror for the fit.
534    func : object
535        func has to be of the form
536
537        ```python
538        import autograd.numpy as anp
539
540        def func(a, x):
541            return a[0] + a[1] * x + a[2] * anp.sinh(x)
542        ```
543
544        For multiple x values func can be of the form
545
546        ```python
547        def func(a, x):
548            (x1, x2) = x
549            return a[0] * x1 ** 2 + a[1] * x2
550        ```
551
552        It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation
553        will not work.
554    silent : bool, optional
555        If True all output to the console is omitted (default False).
556    initial_guess : list
557        can provide an initial guess for the input parameters. Relevant for non-linear
558        fits with many parameters.
559    expected_chisquare : bool
560        If True prints the expected chisquare which is
561        corrected by effects caused by correlated input data.
562        This can take a while as the full correlation matrix
563        has to be calculated (default False).
564    num_grad : bool
565        Use numerical differentiation instead of automatic differentiation to perform the error propagation (default False).
566    n_parms : int, optional
567        Number of fit parameters. Overrides automatic detection of parameter count.
568        Useful when autodetection fails. Must match the length of initial_guess (if provided).
569
570    Notes
571    -----
572    Based on the odrpack orthogonal distance regression library.
573
574    Returns
575    -------
576    output : Fit_result
577        Parameters and information on the fitted result.
578    '''
579
580    output = Fit_result()
581
582    output.fit_function = func
583
584    x = np.array(x)
585
586    x_shape = x.shape
587
588    if kwargs.get('num_grad') is True:
589        jacobian = num_jacobian
590        hessian = num_hessian
591    else:
592        jacobian = auto_jacobian
593        hessian = auto_hessian
594
595    if not callable(func):
596        raise TypeError('func has to be a function.')
597
598    if 'n_parms' in kwargs:
599        n_parms = kwargs.get('n_parms')
600        if not isinstance(n_parms, int):
601            raise TypeError(
602                f"'n_parms' must be an integer, got {n_parms!r} "
603                f"of type {type(n_parms).__name__}."
604            )
605        if n_parms <= 0:
606            raise ValueError(
607                f"'n_parms' must be a positive integer, got {n_parms}."
608            )
609    else:
610        for i in range(100):
611            try:
612                func(np.arange(i), x.T[0])
613            except TypeError:
614                continue
615            except IndexError:
616                continue
617            else:
618                break
619        else:
620            raise RuntimeError("Fit function is not valid.")
621
622        n_parms = i
623
624    if not silent:
625        print('Fit with', n_parms, 'parameter' + 's' * (n_parms > 1))
626
627    x_f = np.vectorize(lambda o: o.value)(x)
628    dx_f = np.vectorize(lambda o: o.dvalue)(x)
629    y_f = np.array([o.value for o in y])
630    dy_f = np.array([o.dvalue for o in y])
631
632    if np.any(np.asarray(dx_f) <= 0.0):
633        raise Exception('No x errors available, run the gamma method first.')
634
635    if np.any(np.asarray(dy_f) <= 0.0):
636        raise Exception('No y errors available, run the gamma method first.')
637
638    if 'initial_guess' in kwargs:
639        x0 = np.asarray(kwargs.get('initial_guess'), dtype=np.float64)
640        if len(x0) != n_parms:
641            raise Exception(f'Initial guess does not have the correct length: {len(x0)} vs. {n_parms}')
642    else:
643        x0 = np.ones(n_parms, dtype=np.float64)
644
645    # odrpack expects f(x, beta), but pyerrors convention is f(beta, x)
646    def wrapped_func(x, beta):
647        return func(beta, x)
648
649    out = odr_fit(
650        wrapped_func,
651        np.asarray(x_f, dtype=np.float64),
652        np.asarray(y_f, dtype=np.float64),
653        beta0=x0,
654        weight_x=1.0 / np.asarray(dx_f, dtype=np.float64) ** 2,
655        weight_y=1.0 / np.asarray(dy_f, dtype=np.float64) ** 2,
656        partol=np.finfo(np.float64).eps,
657        task='explicit-ODR',
658        diff_scheme='central'
659    )
660
661    output.residual_variance = out.res_var
662
663    output.method = 'ODR'
664
665    output.message = out.stopreason
666
667    output.xplus = out.xplusd
668
669    if not silent:
670        print('Method: ODR')
671        print(out.stopreason)
672        print('Residual variance:', output.residual_variance)
673
674    if not out.success:
675        # ODRPACK95 info code structure (see User Guide §4):
676        #   info % 10        -> convergence: 1=sum-of-sq, 2=param, 3=both
677        #   info // 10 % 10  -> 1 = problem not full rank at solution
678        convergence_status = out.info % 10
679        rank_deficient = (out.info // 10 % 10) == 1
680
681        if convergence_status in [1, 2, 3] and rank_deficient:
682            warnings.warn(
683                f"ODR fit is rank deficient (irank={out.irank}, inv_condnum={out.inv_condnum:.2e}). "
684                "This may indicate a vanishing chi-squared (n_obs == n_parms). "
685                "Results may be unreliable.",
686                RuntimeWarning, stacklevel=2
687            )
688        else:
689            raise Exception('The minimization procedure did not converge.')
690
691    m = x_f.size
692
693    def odr_chisquare(p):
694        model = func(p[:n_parms], p[n_parms:].reshape(x_shape))
695        chisq = anp.sum(((y_f - model) / dy_f) ** 2) + anp.sum(((x_f - p[n_parms:].reshape(x_shape)) / dx_f) ** 2)
696        return chisq
697
698    if kwargs.get('expected_chisquare') is True:
699        W = np.diag(1 / np.asarray(np.concatenate((dy_f.ravel(), dx_f.ravel()))))
700
701        if kwargs.get('covariance') is not None:
702            cov = kwargs.get('covariance')
703        else:
704            cov = covariance(np.concatenate((y, x.ravel())))
705
706        number_of_x_parameters = int(m / x_f.shape[-1])
707
708        old_jac = jacobian(func)(out.beta, out.xplusd)
709        fused_row1 = np.concatenate((old_jac, np.concatenate((number_of_x_parameters * [np.zeros(old_jac.shape)]), axis=0)))
710        fused_row2 = np.concatenate((jacobian(lambda x, y: func(y, x))(out.xplusd, out.beta).reshape(x_f.shape[-1], x_f.shape[-1] * number_of_x_parameters), np.identity(number_of_x_parameters * old_jac.shape[0])))
711        new_jac = np.concatenate((fused_row1, fused_row2), axis=1)
712
713        A = W @ new_jac
714        P_phi = A @ np.linalg.pinv(A.T @ A) @ A.T
715        expected_chisquare = np.trace((np.identity(P_phi.shape[0]) - P_phi) @ W @ cov @ W)
716        if expected_chisquare <= 0.0:
717            warnings.warn("Negative expected_chisquare.", RuntimeWarning, stacklevel=2)
718            expected_chisquare = np.abs(expected_chisquare)
719        output.chisquare_by_expected_chisquare = odr_chisquare(np.concatenate((out.beta, out.xplusd.ravel()))) / expected_chisquare
720        if not silent:
721            print('chisquare/expected_chisquare:',
722                  output.chisquare_by_expected_chisquare)
723
724    fitp = out.beta
725    try:
726        hess = hessian(odr_chisquare)(np.concatenate((fitp, out.xplusd.ravel())))
727    except (TypeError, ValueError, np.linalg.LinAlgError):
728        raise Exception("It is required to use autograd.numpy instead of numpy within fit functions, see the documentation for details.") from None
729
730    def odr_chisquare_compact_x(d):
731        model = func(d[:n_parms], d[n_parms:n_parms + m].reshape(x_shape))
732        chisq = anp.sum(((y_f - model) / dy_f) ** 2) + anp.sum(((d[n_parms + m:].reshape(x_shape) - d[n_parms:n_parms + m].reshape(x_shape)) / dx_f) ** 2)
733        return chisq
734
735    jac_jac_x = hessian(odr_chisquare_compact_x)(np.concatenate((fitp, out.xplusd.ravel(), x_f.ravel())))
736
737    # Compute hess^{-1} @ jac_jac_x[:n_parms + m, n_parms + m:] using LAPACK dgesv
738    try:
739        deriv_x = -scipy.linalg.solve(hess, jac_jac_x[:n_parms + m, n_parms + m:])
740    except np.linalg.LinAlgError as err:
741        raise Exception("Cannot invert hessian matrix.") from err
742
743    def odr_chisquare_compact_y(d):
744        model = func(d[:n_parms], d[n_parms:n_parms + m].reshape(x_shape))
745        chisq = anp.sum(((d[n_parms + m:] - model) / dy_f) ** 2) + anp.sum(((x_f - d[n_parms:n_parms + m].reshape(x_shape)) / dx_f) ** 2)
746        return chisq
747
748    jac_jac_y = hessian(odr_chisquare_compact_y)(np.concatenate((fitp, out.xplusd.ravel(), y_f)))
749
750    # Compute hess^{-1} @ jac_jac_y[:n_parms + m, n_parms + m:] using LAPACK dgesv
751    try:
752        deriv_y = -scipy.linalg.solve(hess, jac_jac_y[:n_parms + m, n_parms + m:])
753    except np.linalg.LinAlgError as err:
754        raise Exception("Cannot invert hessian matrix.") from err
755
756    result = []
757    for i in range(n_parms):
758        result.append(derived_observable(lambda my_var, i=i, **kwargs: (my_var[0] + np.finfo(np.float64).eps) / (x.ravel()[0].value + np.finfo(np.float64).eps) * out.beta[i], list(x.ravel()) + list(y), man_grad=list(deriv_x[i]) + list(deriv_y[i])))
759
760    output.fit_parameters = result
761
762    output.odr_chisquare = odr_chisquare(np.concatenate((out.beta, out.xplusd.ravel())))
763    output.dof = x.shape[-1] - n_parms
764    output.p_value = 1 - scipy.stats.chi2.cdf(output.odr_chisquare, output.dof)
765
766    return output
767
768
769def fit_lin(x, y, **kwargs):
770    """Performs a linear fit to y = n + m * x and returns two Obs n, m.
771
772    Parameters
773    ----------
774    x : list
775        Can either be a list of floats in which case no xerror is assumed, or
776        a list of Obs, where the dvalues of the Obs are used as xerror for the fit.
777    y : list
778        List of Obs, the dvalues of the Obs are used as yerror for the fit.
779
780    Returns
781    -------
782    fit_parameters : list[Obs]
783        LIist of fitted observables.
784    """
785
786    def f(a, x):
787        y = a[0] + a[1] * x
788        return y
789
790    if all(isinstance(n, Obs) for n in x):
791        out = total_least_squares(x, y, f, **kwargs)
792        return out.fit_parameters
793    elif all(isinstance(n, float) or isinstance(n, int) for n in x) or isinstance(x, np.ndarray):
794        out = least_squares(x, y, f, **kwargs)
795        return out.fit_parameters
796    else:
797        raise TypeError('Unsupported types for x')
798
799
800def qqplot(x, o_y, func, p, title=""):
801    """Generates a quantile-quantile plot of the fit result which can be used to
802       check if the residuals of the fit are gaussian distributed.
803
804    Returns
805    -------
806    None
807    """
808
809    residuals = []
810    for i_x, i_y in zip(x, o_y, strict=True):
811        residuals.append((i_y - func(p, i_x)) / i_y.dvalue)
812    residuals = sorted(residuals)
813    my_y = [o.value for o in residuals]
814    probplot = scipy.stats.probplot(my_y)
815    my_x = probplot[0][0]
816    plt.figure(figsize=(8, 8 / 1.618))
817    plt.errorbar(my_x, my_y, fmt='o')
818    fit_start = my_x[0]
819    fit_stop = my_x[-1]
820    samples = np.arange(fit_start, fit_stop, 0.01)
821    plt.plot(samples, samples, 'k--', zorder=11, label='Standard normal distribution')
822    plt.plot(samples, probplot[1][0] * samples + probplot[1][1], zorder=10, label='Least squares fit, r=' + str(np.around(probplot[1][2], 3)), marker='', ls='-')
823
824    plt.xlabel('Theoretical quantiles')
825    plt.ylabel('Ordered Values')
826    plt.legend(title=title)
827    plt.draw()
828
829
830def residual_plot(x, y, func, fit_res, title=""):
831    """Generates a plot which compares the fit to the data and displays the corresponding residuals
832
833    For uncorrelated data the residuals are expected to be distributed ~N(0,1).
834
835    Returns
836    -------
837    None
838    """
839    sorted_x = sorted(x)
840    xstart = sorted_x[0] - 0.5 * (sorted_x[1] - sorted_x[0])
841    xstop = sorted_x[-1] + 0.5 * (sorted_x[-1] - sorted_x[-2])
842    x_samples = np.arange(xstart, xstop + 0.01, 0.01)
843
844    plt.figure(figsize=(8, 8 / 1.618))
845    gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1], wspace=0.0, hspace=0.0)
846    ax0 = plt.subplot(gs[0])
847    ax0.errorbar(x, [o.value for o in y], yerr=[o.dvalue for o in y], ls='none', fmt='o', capsize=3, markersize=5, label='Data')
848    ax0.plot(x_samples, func([o.value for o in fit_res], x_samples), label='Fit', zorder=10, ls='-', ms=0)
849    ax0.set_xticklabels([])
850    ax0.set_xlim([xstart, xstop])
851    ax0.set_xticklabels([])
852    ax0.legend(title=title)
853
854    residuals = (np.asarray([o.value for o in y]) - func([o.value for o in fit_res], np.asarray(x))) / np.asarray([o.dvalue for o in y])
855    ax1 = plt.subplot(gs[1])
856    ax1.plot(x, residuals, 'ko', ls='none', markersize=5)
857    ax1.tick_params(direction='out')
858    ax1.tick_params(axis="x", bottom=True, top=True, labelbottom=True)
859    ax1.axhline(y=0.0, ls='--', color='k', marker=" ")
860    ax1.fill_between(x_samples, -1.0, 1.0, alpha=0.1, facecolor='k')
861    ax1.set_xlim([xstart, xstop])
862    ax1.set_ylabel('Residuals')
863    plt.subplots_adjust(wspace=None, hspace=None)
864    plt.draw()
865
866
867def error_band(x, func, beta):
868    """Calculate the error band for an array of sample values x, for given fit function func with optimized parameters beta.
869
870    Returns
871    -------
872    err : np.array(Obs)
873        Error band for an array of sample values x
874    """
875    cov = covariance(beta)
876    if np.any(np.abs(cov - cov.T) > 1000 * np.finfo(np.float64).eps):
877        warnings.warn("Covariance matrix is not symmetric within floating point precision", RuntimeWarning, stacklevel=2)
878
879    deriv = []
880    for item in x:
881        deriv.append(np.array(egrad(func)([o.value for o in beta], item)))
882
883    err = []
884    for i, _item in enumerate(x):
885        err.append(np.sqrt(deriv[i] @ cov @ deriv[i]))
886    err = np.array(err)
887
888    return err
889
890
891def ks_test(objects=None):
892    """Performs a Kolmogorov–Smirnov test for the p-values of all fit object.
893
894    Parameters
895    ----------
896    objects : list
897        List of fit results to include in the analysis (optional).
898
899    Returns
900    -------
901    None
902    """
903
904    if objects is None:
905        obs_list = []
906        for obj in gc.get_objects():
907            if isinstance(obj, Fit_result):
908                obs_list.append(obj)
909    else:
910        obs_list = objects
911
912    p_values = [o.p_value for o in obs_list]
913
914    bins = len(p_values)
915    x = np.arange(0, 1.001, 0.001)
916    plt.plot(x, x, 'k', zorder=1)
917    plt.xlim(0, 1)
918    plt.ylim(0, 1)
919    plt.xlabel('p-value')
920    plt.ylabel('Cumulative probability')
921    plt.title(str(bins) + ' p-values')
922
923    n = np.arange(1, bins + 1) / np.float64(bins)
924    Xs = np.sort(p_values)
925    plt.step(Xs, n)
926    diffs = n - Xs
927    loc_max_diff = np.argmax(np.abs(diffs))
928    loc = Xs[loc_max_diff]
929    plt.annotate('', xy=(loc, loc), xytext=(loc, loc + diffs[loc_max_diff]), arrowprops=dict(arrowstyle='<->', shrinkA=0, shrinkB=0))
930    plt.draw()
931
932    print(scipy.stats.kstest(p_values, 'uniform'))
933
934
935def _extract_val_and_dval(string):
936    split_string = string.split('(')
937    if '.' in split_string[0] and '.' not in split_string[1][:-1]:
938        factor = 10 ** -len(split_string[0].partition('.')[2])
939    else:
940        factor = 1
941    return float(split_string[0]), float(split_string[1][:-1]) * factor
942
943
944def _construct_prior_obs(i_prior, i_n):
945    if isinstance(i_prior, Obs):
946        return i_prior
947    elif isinstance(i_prior, str):
948        loc_val, loc_dval = _extract_val_and_dval(i_prior)
949        return cov_Obs(loc_val, loc_dval ** 2, '#prior' + str(i_n) + f"_{np.random.randint(2147483647):010d}")  # noqa: NPY002
950    else:
951        raise TypeError("Prior entries need to be 'Obs' or 'str'.")
class Fit_result(collections.abc.Sequence):
23class Fit_result(Sequence):
24    """Represents fit results.
25
26    Attributes
27    ----------
28    fit_parameters : list
29        results for the individual fit parameters,
30        also accessible via indices.
31    chisquare_by_dof : float
32        reduced chisquare.
33    p_value : float
34        p-value of the fit
35    t2_p_value : float
36        Hotelling t-squared p-value for correlated fits.
37    """
38
39    def __init__(self):
40        self.fit_parameters = None
41
42    def __getitem__(self, idx):
43        return self.fit_parameters[idx]
44
45    def __len__(self):
46        return len(self.fit_parameters)
47
48    def gamma_method(self, **kwargs):
49        """Apply the gamma method to all fit parameters"""
50        [o.gamma_method(**kwargs) for o in self.fit_parameters]
51
52    gm = gamma_method
53
54    def __str__(self):
55        my_str = 'Goodness of fit:\n'
56        if hasattr(self, 'chisquare_by_dof'):
57            my_str += '\u03C7\u00b2/d.o.f. = ' + f'{self.chisquare_by_dof:2.6f}' + '\n'
58        elif hasattr(self, 'residual_variance'):
59            my_str += 'residual variance = ' + f'{self.residual_variance:2.6f}' + '\n'
60        if hasattr(self, 'chisquare_by_expected_chisquare'):
61            my_str += '\u03C7\u00b2/\u03C7\u00b2exp  = ' + f'{self.chisquare_by_expected_chisquare:2.6f}' + '\n'
62        if hasattr(self, 'p_value'):
63            my_str += 'p-value   = ' + f'{self.p_value:2.4f}' + '\n'
64        if hasattr(self, 't2_p_value'):
65            my_str += 't\u00B2p-value = ' + f'{self.t2_p_value:2.4f}' + '\n'
66        my_str += 'Fit parameters:\n'
67        for i_par, par in enumerate(self.fit_parameters):
68            my_str += str(i_par) + '\t' + ' ' * int(par >= 0) + str(par).rjust(int(par < 0.0)) + '\n'
69        return my_str
70
71    def __repr__(self):
72        m = max(map(len, list(self.__dict__.keys()))) + 1
73        return '\n'.join([key.rjust(m) + ': ' + repr(value) for key, value in sorted(self.__dict__.items())])

Represents fit results.

Attributes
  • fit_parameters (list): results for the individual fit parameters, also accessible via indices.
  • chisquare_by_dof (float): reduced chisquare.
  • p_value (float): p-value of the fit
  • t2_p_value (float): Hotelling t-squared p-value for correlated fits.
fit_parameters
def gamma_method(self, **kwargs):
48    def gamma_method(self, **kwargs):
49        """Apply the gamma method to all fit parameters"""
50        [o.gamma_method(**kwargs) for o in self.fit_parameters]

Apply the gamma method to all fit parameters

def gm(self, **kwargs):
48    def gamma_method(self, **kwargs):
49        """Apply the gamma method to all fit parameters"""
50        [o.gamma_method(**kwargs) for o in self.fit_parameters]

Apply the gamma method to all fit parameters

def least_squares(x, y, func, priors=None, silent=False, **kwargs):
 76def least_squares(x, y, func, priors=None, silent=False, **kwargs):
 77    r'''Performs a non-linear fit to y = func(x).
 78        ```
 79
 80    Parameters
 81    ----------
 82    For an uncombined fit:
 83
 84    x : list
 85        list of floats.
 86    y : list
 87        list of Obs.
 88    func : object
 89        fit function, has to be of the form
 90
 91        ```python
 92        import autograd.numpy as anp
 93
 94        def func(a, x):
 95            return a[0] + a[1] * x + a[2] * anp.sinh(x)
 96        ```
 97
 98        For multiple x values func can be of the form
 99
100        ```python
101        def func(a, x):
102            (x1, x2) = x
103            return a[0] * x1 ** 2 + a[1] * x2
104        ```
105        It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation
106        will not work.
107
108    OR For a combined fit:
109
110    x : dict
111        dict of lists.
112    y : dict
113        dict of lists of Obs.
114    funcs : dict
115        dict of objects
116        fit functions have to be of the form (here a[0] is the common fit parameter)
117        ```python
118        import autograd.numpy as anp
119        funcs = {"a": func_a,
120                "b": func_b}
121
122        def func_a(a, x):
123            return a[1] * anp.exp(-a[0] * x)
124
125        def func_b(a, x):
126            return a[2] * anp.exp(-a[0] * x)
127
128        It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation
129        will not work.
130
131    priors : dict or list, optional
132        priors can either be a dictionary with integer keys and the corresponding priors as values or
133        a list with an entry for every parameter in the fit. The entries can either be
134        Obs (e.g. results from a previous fit) or strings containing a value and an error formatted like
135        0.548(23), 500(40) or 0.5(0.4)
136    silent : bool, optional
137        If True all output to the console is omitted (default False).
138    initial_guess : list
139        can provide an initial guess for the input parameters. Relevant for
140        non-linear fits with many parameters. In case of correlated fits the guess is used to perform
141        an uncorrelated fit which then serves as guess for the correlated fit.
142    method : str, optional
143        can be used to choose an alternative method for the minimization of chisquare.
144        The possible methods are the ones which can be used for scipy.optimize.minimize and
145        migrad of iminuit. If no method is specified, Levenberg–Marquardt is used.
146        Reliable alternatives are migrad, Powell and Nelder-Mead.
147    tol: float, optional
148        can be used (only for combined fits and methods other than Levenberg–Marquardt) to set the tolerance for convergence
149        to a different value to either speed up convergence at the cost of a larger error on the fitted parameters (and possibly
150        invalid estimates for parameter uncertainties) or smaller values to get more accurate parameter values
151        The stopping criterion depends on the method, e.g. migrad: edm_max = 0.002 * tol * errordef (EDM criterion: edm < edm_max)
152    correlated_fit : bool
153        If True, use the full inverse covariance matrix in the definition of the chisquare cost function.
154        For details about how the covariance matrix is estimated see `pyerrors.obs.covariance`.
155        In practice the correlation matrix is Cholesky decomposed and inverted (instead of the covariance matrix).
156        This procedure should be numerically more stable as the correlation matrix is typically better conditioned (Jacobi preconditioning).
157    inv_chol_cov_matrix [array,list], optional
158        array: shape = (number of y values) X (number of y values)
159        list:   for an uncombined fit: [""]
160                for a combined fit: list of keys belonging to the corr_matrix saved in the array, must be the same as the keys of the y dict in alphabetical order
161        If correlated_fit=True is set as well, can provide an inverse covariance matrix (y errors, dy_f included!) of your own choosing for a correlated fit.
162        The matrix must be a lower triangular matrix constructed from a Cholesky decomposition: The function invert_corr_cov_cholesky(corr, inverrdiag) can be
163        used to construct it from a correlation matrix (corr) and the errors dy_f of the data points (inverrdiag = np.diag(1 / np.asarray(dy_f))). For the correct
164        ordering the correlation matrix (corr) can be sorted via the function sort_corr(corr, kl, yd) where kl is the list of keys and yd the y dict.
165    expected_chisquare : bool
166        If True estimates the expected chisquare which is
167        corrected by effects caused by correlated input data (default False).
168    resplot : bool
169        If True, a plot which displays fit, data and residuals is generated (default False).
170    qqplot : bool
171        If True, a quantile-quantile plot of the fit result is generated (default False).
172    num_grad : bool
173        Use numerical differentation instead of automatic differentiation to perform the error propagation (default False).
174    n_parms : int, optional
175        Number of fit parameters. Overrides automatic detection of parameter count.
176        Useful when autodetection fails. Must match the length of initial_guess or priors (if provided).
177
178    Returns
179    -------
180    output : Fit_result
181        Parameters and information on the fitted result.
182    Examples
183    ------
184    >>> # Example of a correlated (correlated_fit = True, inv_chol_cov_matrix handed over) combined fit, based on a randomly generated data set
185    >>> import numpy as np
186    >>> from scipy.stats import norm
187    >>> from scipy.linalg import cholesky
188    >>> import pyerrors as pe
189    >>> # generating the random data set
190    >>> num_samples = 400
191    >>> N = 3
192    >>> x = np.arange(N)
193    >>> x1 = norm.rvs(size=(N, num_samples)) # generate random numbers
194    >>> x2 = norm.rvs(size=(N, num_samples)) # generate random numbers
195    >>> r = r1 = r2 = np.zeros((N, N))
196    >>> y = {}
197    >>> for i in range(N):
198    >>>    for j in range(N):
199    >>>        r[i, j] = np.exp(-0.8 * np.fabs(i - j)) # element in correlation matrix
200    >>> errl = np.sqrt([3.4, 2.5, 3.6]) # set y errors
201    >>> for i in range(N):
202    >>>    for j in range(N):
203    >>>        r[i, j] *= errl[i] * errl[j] # element in covariance matrix
204    >>> c = cholesky(r, lower=True)
205    >>> y = {'a': np.dot(c, x1), 'b': np.dot(c, x2)} # generate y data with the covariance matrix defined
206    >>> # random data set has been generated, now the dictionaries and the inverse covariance matrix to be handed over are built
207    >>> x_dict = {}
208    >>> y_dict = {}
209    >>> chol_inv_dict = {}
210    >>> data = []
211    >>> for key in y.keys():
212    >>>    x_dict[key] = x
213    >>>    for i in range(N):
214    >>>        data.append(pe.Obs([[i + 1 + o for o in y[key][i]]], ['ens'])) # generate y Obs from the y data
215    >>>    [o.gamma_method() for o in data]
216    >>>    corr = pe.covariance(data, correlation=True)
217    >>>    inverrdiag = np.diag(1 / np.asarray([o.dvalue for o in data]))
218    >>>    chol_inv = pe.obs.invert_corr_cov_cholesky(corr, inverrdiag) # gives form of the inverse covariance matrix needed for the combined correlated fit below
219    >>> y_dict = {'a': data[:3], 'b': data[3:]}
220    >>> # common fit parameter p[0] in combined fit
221    >>> def fit1(p, x):
222    >>>    return p[0] + p[1] * x
223    >>> def fit2(p, x):
224    >>>    return p[0] + p[2] * x
225    >>> fitf_dict = {'a': fit1, 'b':fit2}
226    >>> fitp_inv_cov_combined_fit = pe.least_squares(x_dict,y_dict, fitf_dict, correlated_fit = True, inv_chol_cov_matrix = [chol_inv,['a','b']])
227    Fit with 3 parameters
228    Method: Levenberg-Marquardt
229    `ftol` termination condition is satisfied.
230    chisquare/d.o.f.: 0.5388013574561786 # random
231    fit parameters [1.11897846 0.96361162 0.92325319] # random
232
233    '''
234    output = Fit_result()
235
236    if (isinstance(x, dict) and isinstance(y, dict) and isinstance(func, dict)):
237        xd = {key: anp.asarray(x[key]) for key in x}
238        yd = y
239        funcd = func
240        output.fit_function = func
241    elif (isinstance(x, dict) or isinstance(y, dict) or isinstance(func, dict)):
242        raise TypeError("All arguments have to be dictionaries in order to perform a combined fit.")
243    else:
244        x = np.asarray(x)
245        xd = {"": x}
246        yd = {"": y}
247        funcd = {"": func}
248        output.fit_function = func
249
250    if kwargs.get('num_grad') is True:
251        jacobian = num_jacobian
252        hessian = num_hessian
253    else:
254        jacobian = auto_jacobian
255        hessian = auto_hessian
256
257    key_ls = sorted(list(xd.keys()))
258
259    if sorted(list(yd.keys())) != key_ls:
260        raise ValueError('x and y dictionaries do not contain the same keys.')
261
262    if sorted(list(funcd.keys())) != key_ls:
263        raise ValueError('x and func dictionaries do not contain the same keys.')
264
265    x_all = np.concatenate([np.array(xd[key]).transpose() for key in key_ls]).transpose()
266    y_all = np.concatenate([np.array(yd[key]) for key in key_ls])
267
268    y_f = [o.value for o in y_all]
269    dy_f = [o.dvalue for o in y_all]
270
271    if len(x_all.shape) > 2:
272        raise ValueError("Unknown format for x values")
273
274    if np.any(np.asarray(dy_f) <= 0.0):
275        raise Exception("No y errors available, run the gamma method first.")
276
277    # number of fit parameters
278    if 'n_parms' in kwargs:
279        n_parms = kwargs.get('n_parms')
280        if not isinstance(n_parms, int):
281            raise TypeError(
282                f"'n_parms' must be an integer, got {n_parms!r} "
283                f"of type {type(n_parms).__name__}."
284            )
285        if n_parms <= 0:
286            raise ValueError(
287                f"'n_parms' must be a positive integer, got {n_parms}."
288            )
289    else:
290        n_parms_ls = []
291        for key in key_ls:
292            if not callable(funcd[key]):
293                raise TypeError('func (key=' + key + ') is not a function.')
294            if np.asarray(xd[key]).shape[-1] != len(yd[key]):
295                raise ValueError('x and y input (key=' + key + ') do not have the same length')
296            for n_loc in range(100):
297                try:
298                    funcd[key](np.arange(n_loc), x_all.T[0])
299                except TypeError:
300                    continue
301                except IndexError:
302                    continue
303                else:
304                    break
305            else:
306                raise RuntimeError("Fit function (key=" + key + ") is not valid.")
307            n_parms_ls.append(n_loc)
308
309        n_parms = max(n_parms_ls)
310
311    if len(key_ls) > 1:
312        for key in key_ls:
313            if np.asarray(yd[key]).shape != funcd[key](np.arange(n_parms), xd[key]).shape:
314                raise ValueError(f"Fit function {key} returns the wrong shape ({funcd[key](np.arange(n_parms), xd[key]).shape} instead of {np.asarray(yd[key]).shape})\nIf the fit function is just a constant you could try adding x*0 to get the correct shape.")
315
316    if not silent:
317        print('Fit with', n_parms, 'parameter' + 's' * (n_parms > 1))
318
319    if priors is not None:
320        if isinstance(priors, (list, np.ndarray)):
321            if n_parms != len(priors):
322                raise ValueError("'priors' does not have the correct length.")
323
324            loc_priors = []
325            for i_n, i_prior in enumerate(priors):
326                loc_priors.append(_construct_prior_obs(i_prior, i_n))
327
328            prior_mask = np.arange(len(priors))
329            output.priors = loc_priors
330
331        elif isinstance(priors, dict):
332            loc_priors = []
333            prior_mask = []
334            output.priors = {}
335            for pos, prior in priors.items():
336                if isinstance(pos, int):
337                    prior_mask.append(pos)
338                else:
339                    raise TypeError("Prior position needs to be an integer.")
340                loc_priors.append(_construct_prior_obs(prior, pos))
341
342                output.priors[pos] = loc_priors[-1]
343            if max(prior_mask) >= n_parms:
344                raise ValueError("Prior position out of range.")
345        else:
346            raise TypeError("Unkown type for `priors`.")
347
348        p_f = [o.value for o in loc_priors]
349        dp_f = [o.dvalue for o in loc_priors]
350        if np.any(np.asarray(dp_f) <= 0.0):
351            raise Exception("No prior errors available, run the gamma method first.")
352    else:
353        p_f = dp_f = np.array([])
354        prior_mask = []
355        loc_priors = []
356
357    if 'initial_guess' in kwargs:
358        x0 = kwargs.get('initial_guess')
359        if len(x0) != n_parms:
360            raise ValueError(f'Initial guess does not have the correct length: {len(x0)} vs. {n_parms}')
361    else:
362        x0 = [0.1] * n_parms
363
364    if priors is None:
365        def general_chisqfunc_uncorr(p, ivars, pr):
366            model = anp.concatenate([anp.array(funcd[key](p, xd[key])).reshape(-1) for key in key_ls])
367            return (ivars - model) / dy_f
368    else:
369        def general_chisqfunc_uncorr(p, ivars, pr):
370            model = anp.concatenate([anp.array(funcd[key](p, xd[key])).reshape(-1) for key in key_ls])
371            return anp.concatenate(((ivars - model) / dy_f, (p[prior_mask] - pr) / dp_f))
372
373    def chisqfunc_uncorr(p):
374        return anp.sum(general_chisqfunc_uncorr(p, y_f, p_f) ** 2)
375
376    if kwargs.get('correlated_fit') is True:
377        if 'inv_chol_cov_matrix' in kwargs:
378            chol_inv = kwargs.get('inv_chol_cov_matrix')
379            if (chol_inv[0].shape[0] != len(dy_f)):
380                raise TypeError('The number of columns of the inverse covariance matrix handed over needs to be equal to the number of y errors.')
381            if (chol_inv[0].shape[0] != chol_inv[0].shape[1]):
382                raise TypeError('The inverse covariance matrix handed over needs to have the same number of rows as columns.')
383            if (chol_inv[1] != key_ls):
384                raise ValueError('The keys of inverse covariance matrix are not the same or do not appear in the same order as the x and y values.')
385            chol_inv = chol_inv[0]
386            if np.any(np.diag(chol_inv) <= 0) or (not np.all(chol_inv == np.tril(chol_inv))):
387                raise ValueError('The inverse covariance matrix inv_chol_cov_matrix[0] has to be a lower triangular matrix constructed from a Cholesky decomposition.')
388        else:
389            corr = covariance(y_all, correlation=True, **kwargs)
390            inverrdiag = np.diag(1 / np.asarray(dy_f))
391            chol_inv = invert_corr_cov_cholesky(corr, inverrdiag)
392
393        def general_chisqfunc(p, ivars, pr):
394            model = anp.concatenate([anp.array(funcd[key](p, xd[key])).reshape(-1) for key in key_ls])
395            return anp.concatenate((anp.dot(chol_inv, (ivars - model)), (p[prior_mask] - pr) / dp_f))
396
397        def chisqfunc(p):
398            return anp.sum(general_chisqfunc(p, y_f, p_f) ** 2)
399    else:
400        general_chisqfunc = general_chisqfunc_uncorr
401        chisqfunc = chisqfunc_uncorr
402
403    output.method = kwargs.get('method', 'Levenberg-Marquardt')
404    if not silent:
405        print('Method:', output.method)
406
407    if output.method != 'Levenberg-Marquardt':
408        if output.method == 'migrad':
409            tolerance = 1e-4  # default value of 1e-1 set by iminuit can be problematic
410            if 'tol' in kwargs:
411                tolerance = kwargs.get('tol')
412            fit_result = iminuit.minimize(chisqfunc_uncorr, x0, tol=tolerance)  # Stopping criterion 0.002 * tol * errordef
413            if kwargs.get('correlated_fit') is True:
414                fit_result = iminuit.minimize(chisqfunc, fit_result.x, tol=tolerance)
415            output.iterations = fit_result.nfev
416        else:
417            tolerance = 1e-12
418            if 'tol' in kwargs:
419                tolerance = kwargs.get('tol')
420            fit_result = scipy.optimize.minimize(chisqfunc_uncorr, x0, method=kwargs.get('method'), tol=tolerance)
421            if kwargs.get('correlated_fit') is True:
422                fit_result = scipy.optimize.minimize(chisqfunc, fit_result.x, method=kwargs.get('method'), tol=tolerance)
423            output.iterations = fit_result.nit
424
425        chisquare = fit_result.fun
426
427    else:
428        if 'tol' in kwargs:
429            print('tol cannot be set for Levenberg-Marquardt')
430
431        def chisqfunc_residuals_uncorr(p):
432            return general_chisqfunc_uncorr(p, y_f, p_f)
433
434        fit_result = scipy.optimize.least_squares(chisqfunc_residuals_uncorr, x0, method='lm', ftol=1e-15, gtol=1e-15, xtol=1e-15)
435        if kwargs.get('correlated_fit') is True:
436            def chisqfunc_residuals(p):
437                return general_chisqfunc(p, y_f, p_f)
438
439            fit_result = scipy.optimize.least_squares(chisqfunc_residuals, fit_result.x, method='lm', ftol=1e-15, gtol=1e-15, xtol=1e-15)
440
441        chisquare = np.sum(fit_result.fun ** 2)
442        assert np.isclose(chisquare, chisqfunc(fit_result.x), atol=1e-14)
443
444        output.iterations = fit_result.nfev
445
446    if not fit_result.success:
447        raise Exception('The minimization procedure did not converge.')
448
449    output.chisquare = chisquare
450    output.dof = y_all.shape[-1] - n_parms + len(loc_priors)
451    output.p_value = 1 - scipy.stats.chi2.cdf(output.chisquare, output.dof)
452    if output.dof > 0:
453        output.chisquare_by_dof = output.chisquare / output.dof
454    else:
455        output.chisquare_by_dof = float('nan')
456
457    output.message = fit_result.message
458    if not silent:
459        print(fit_result.message)
460        print('chisquare/d.o.f.:', output.chisquare_by_dof)
461        print('fit parameters', fit_result.x)
462
463    def prepare_hat_matrix():
464        hat_vector = []
465        for key in key_ls:
466            if (len(xd[key]) != 0):
467                hat_vector.append(jacobian(funcd[key])(fit_result.x, xd[key]))
468        hat_vector = [item for sublist in hat_vector for item in sublist]
469        return hat_vector
470
471    if kwargs.get('expected_chisquare') is True:
472        if kwargs.get('correlated_fit') is not True:
473            W = np.diag(1 / np.asarray(dy_f))
474            cov = covariance(y_all)
475            hat_vector = prepare_hat_matrix()
476            A = W @ hat_vector
477            P_phi = A @ np.linalg.pinv(A.T @ A) @ A.T
478            expected_chisquare = np.trace((np.identity(y_all.shape[-1]) - P_phi) @ W @ cov @ W) + len(loc_priors)
479            output.chisquare_by_expected_chisquare = output.chisquare / expected_chisquare
480            if not silent:
481                print('chisquare/expected_chisquare:', output.chisquare_by_expected_chisquare)
482
483    fitp = fit_result.x
484
485    try:
486        hess = hessian(chisqfunc)(fitp)
487    except (TypeError, ValueError, np.linalg.LinAlgError):
488        raise Exception("It is required to use autograd.numpy instead of numpy within fit functions, see the documentation for details.") from None
489
490    len_y = len(y_f)
491
492    def chisqfunc_compact(d):
493        return anp.sum(general_chisqfunc(d[:n_parms], d[n_parms: n_parms + len_y], d[n_parms + len_y:]) ** 2)
494
495    jac_jac_y = hessian(chisqfunc_compact)(np.concatenate((fitp, y_f, p_f)))
496
497    # Compute hess^{-1} @ jac_jac_y[:n_parms + m, n_parms + m:] using LAPACK dgesv
498    try:
499        deriv_y = -scipy.linalg.solve(hess, jac_jac_y[:n_parms, n_parms:])
500    except np.linalg.LinAlgError as err:
501        raise Exception("Cannot invert hessian matrix.") from err
502
503    result = []
504    for i in range(n_parms):
505        result.append(derived_observable(lambda x_all, i=i, **kwargs: (x_all[0] + np.finfo(np.float64).eps) / (y_all[0].value + np.finfo(np.float64).eps) * fitp[i], list(y_all) + loc_priors, man_grad=list(deriv_y[i])))
506
507    output.fit_parameters = result
508
509    # Hotelling t-squared p-value for correlated fits.
510    if kwargs.get('correlated_fit') is True:
511        n_cov = np.min(np.vectorize(lambda x_all: x_all.N)(y_all))
512        output.t2_p_value = 1 - scipy.stats.f.cdf((n_cov - output.dof) / (output.dof * (n_cov - 1)) * output.chisquare,
513                                                  output.dof, n_cov - output.dof)
514
515    if kwargs.get('resplot') is True:
516        for key in key_ls:
517            residual_plot(xd[key], yd[key], funcd[key], result, title=key)
518
519    if kwargs.get('qqplot') is True:
520        for key in key_ls:
521            qqplot(xd[key], yd[key], funcd[key], result, title=key)
522
523    return output

Performs a non-linear fit to y = func(x). ```

Parameters
  • For an uncombined fit:
  • x (list): list of floats.
  • y (list): list of Obs.
  • func (object): fit function, has to be of the form

    import autograd.numpy as anp
    
    def func(a, x):
        return a[0] + a[1] * x + a[2] * anp.sinh(x)
    

    For multiple x values func can be of the form

    def func(a, x):
        (x1, x2) = x
        return a[0] * x1 ** 2 + a[1] * x2
    

    It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation will not work.

  • OR For a combined fit:
  • x (dict): dict of lists.
  • y (dict): dict of lists of Obs.
  • funcs (dict): dict of objects fit functions have to be of the form (here a[0] is the common fit parameter) ```python import autograd.numpy as anp funcs = {"a": func_a, "b": func_b}

    def func_a(a, x): return a[1] * anp.exp(-a[0] * x)

    def func_b(a, x): return a[2] * anp.exp(-a[0] * x)

    It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation will not work.

  • priors (dict or list, optional): priors can either be a dictionary with integer keys and the corresponding priors as values or a list with an entry for every parameter in the fit. The entries can either be Obs (e.g. results from a previous fit) or strings containing a value and an error formatted like 0.548(23), 500(40) or 0.5(0.4)
  • silent (bool, optional): If True all output to the console is omitted (default False).
  • initial_guess (list): can provide an initial guess for the input parameters. Relevant for non-linear fits with many parameters. In case of correlated fits the guess is used to perform an uncorrelated fit which then serves as guess for the correlated fit.
  • method (str, optional): can be used to choose an alternative method for the minimization of chisquare. The possible methods are the ones which can be used for scipy.optimize.minimize and migrad of iminuit. If no method is specified, Levenberg–Marquardt is used. Reliable alternatives are migrad, Powell and Nelder-Mead.
  • tol (float, optional): can be used (only for combined fits and methods other than Levenberg–Marquardt) to set the tolerance for convergence to a different value to either speed up convergence at the cost of a larger error on the fitted parameters (and possibly invalid estimates for parameter uncertainties) or smaller values to get more accurate parameter values The stopping criterion depends on the method, e.g. migrad: edm_max = 0.002 * tol * errordef (EDM criterion: edm < edm_max)
  • correlated_fit (bool): If True, use the full inverse covariance matrix in the definition of the chisquare cost function. For details about how the covariance matrix is estimated see pyerrors.obs.covariance. In practice the correlation matrix is Cholesky decomposed and inverted (instead of the covariance matrix). This procedure should be numerically more stable as the correlation matrix is typically better conditioned (Jacobi preconditioning).
  • inv_chol_cov_matrix [array,list], optional: array: shape = (number of y values) X (number of y values) list: for an uncombined fit: [""] for a combined fit: list of keys belonging to the corr_matrix saved in the array, must be the same as the keys of the y dict in alphabetical order If correlated_fit=True is set as well, can provide an inverse covariance matrix (y errors, dy_f included!) of your own choosing for a correlated fit. The matrix must be a lower triangular matrix constructed from a Cholesky decomposition: The function invert_corr_cov_cholesky(corr, inverrdiag) can be used to construct it from a correlation matrix (corr) and the errors dy_f of the data points (inverrdiag = np.diag(1 / np.asarray(dy_f))). For the correct ordering the correlation matrix (corr) can be sorted via the function sort_corr(corr, kl, yd) where kl is the list of keys and yd the y dict.
  • expected_chisquare (bool): If True estimates the expected chisquare which is corrected by effects caused by correlated input data (default False).
  • resplot (bool): If True, a plot which displays fit, data and residuals is generated (default False).
  • qqplot (bool): If True, a quantile-quantile plot of the fit result is generated (default False).
  • num_grad (bool): Use numerical differentation instead of automatic differentiation to perform the error propagation (default False).
  • n_parms (int, optional): Number of fit parameters. Overrides automatic detection of parameter count. Useful when autodetection fails. Must match the length of initial_guess or priors (if provided).
Returns
  • output (Fit_result): Parameters and information on the fitted result.
Examples
>>> # Example of a correlated (correlated_fit = True, inv_chol_cov_matrix handed over) combined fit, based on a randomly generated data set
>>> import numpy as np
>>> from scipy.stats import norm
>>> from scipy.linalg import cholesky
>>> import pyerrors as pe
>>> # generating the random data set
>>> num_samples = 400
>>> N = 3
>>> x = np.arange(N)
>>> x1 = norm.rvs(size=(N, num_samples)) # generate random numbers
>>> x2 = norm.rvs(size=(N, num_samples)) # generate random numbers
>>> r = r1 = r2 = np.zeros((N, N))
>>> y = {}
>>> for i in range(N):
>>>    for j in range(N):
>>>        r[i, j] = np.exp(-0.8 * np.fabs(i - j)) # element in correlation matrix
>>> errl = np.sqrt([3.4, 2.5, 3.6]) # set y errors
>>> for i in range(N):
>>>    for j in range(N):
>>>        r[i, j] *= errl[i] * errl[j] # element in covariance matrix
>>> c = cholesky(r, lower=True)
>>> y = {'a': np.dot(c, x1), 'b': np.dot(c, x2)} # generate y data with the covariance matrix defined
>>> # random data set has been generated, now the dictionaries and the inverse covariance matrix to be handed over are built
>>> x_dict = {}
>>> y_dict = {}
>>> chol_inv_dict = {}
>>> data = []
>>> for key in y.keys():
>>>    x_dict[key] = x
>>>    for i in range(N):
>>>        data.append(pe.Obs([[i + 1 + o for o in y[key][i]]], ['ens'])) # generate y Obs from the y data
>>>    [o.gamma_method() for o in data]
>>>    corr = pe.covariance(data, correlation=True)
>>>    inverrdiag = np.diag(1 / np.asarray([o.dvalue for o in data]))
>>>    chol_inv = pe.obs.invert_corr_cov_cholesky(corr, inverrdiag) # gives form of the inverse covariance matrix needed for the combined correlated fit below
>>> y_dict = {'a': data[:3], 'b': data[3:]}
>>> # common fit parameter p[0] in combined fit
>>> def fit1(p, x):
>>>    return p[0] + p[1] * x
>>> def fit2(p, x):
>>>    return p[0] + p[2] * x
>>> fitf_dict = {'a': fit1, 'b':fit2}
>>> fitp_inv_cov_combined_fit = pe.least_squares(x_dict,y_dict, fitf_dict, correlated_fit = True, inv_chol_cov_matrix = [chol_inv,['a','b']])
Fit with 3 parameters
Method: Levenberg-Marquardt
`ftol` termination condition is satisfied.
chisquare/d.o.f.: 0.5388013574561786 # random
fit parameters [1.11897846 0.96361162 0.92325319] # random
def total_least_squares(x, y, func, silent=False, **kwargs):
526def total_least_squares(x, y, func, silent=False, **kwargs):
527    r'''Performs a non-linear fit to y = func(x) and returns a list of Obs corresponding to the fit parameters.
528
529    Parameters
530    ----------
531    x : list
532        list of Obs, or a tuple of lists of Obs
533    y : list
534        list of Obs. The dvalues of the Obs are used as x- and yerror for the fit.
535    func : object
536        func has to be of the form
537
538        ```python
539        import autograd.numpy as anp
540
541        def func(a, x):
542            return a[0] + a[1] * x + a[2] * anp.sinh(x)
543        ```
544
545        For multiple x values func can be of the form
546
547        ```python
548        def func(a, x):
549            (x1, x2) = x
550            return a[0] * x1 ** 2 + a[1] * x2
551        ```
552
553        It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation
554        will not work.
555    silent : bool, optional
556        If True all output to the console is omitted (default False).
557    initial_guess : list
558        can provide an initial guess for the input parameters. Relevant for non-linear
559        fits with many parameters.
560    expected_chisquare : bool
561        If True prints the expected chisquare which is
562        corrected by effects caused by correlated input data.
563        This can take a while as the full correlation matrix
564        has to be calculated (default False).
565    num_grad : bool
566        Use numerical differentiation instead of automatic differentiation to perform the error propagation (default False).
567    n_parms : int, optional
568        Number of fit parameters. Overrides automatic detection of parameter count.
569        Useful when autodetection fails. Must match the length of initial_guess (if provided).
570
571    Notes
572    -----
573    Based on the odrpack orthogonal distance regression library.
574
575    Returns
576    -------
577    output : Fit_result
578        Parameters and information on the fitted result.
579    '''
580
581    output = Fit_result()
582
583    output.fit_function = func
584
585    x = np.array(x)
586
587    x_shape = x.shape
588
589    if kwargs.get('num_grad') is True:
590        jacobian = num_jacobian
591        hessian = num_hessian
592    else:
593        jacobian = auto_jacobian
594        hessian = auto_hessian
595
596    if not callable(func):
597        raise TypeError('func has to be a function.')
598
599    if 'n_parms' in kwargs:
600        n_parms = kwargs.get('n_parms')
601        if not isinstance(n_parms, int):
602            raise TypeError(
603                f"'n_parms' must be an integer, got {n_parms!r} "
604                f"of type {type(n_parms).__name__}."
605            )
606        if n_parms <= 0:
607            raise ValueError(
608                f"'n_parms' must be a positive integer, got {n_parms}."
609            )
610    else:
611        for i in range(100):
612            try:
613                func(np.arange(i), x.T[0])
614            except TypeError:
615                continue
616            except IndexError:
617                continue
618            else:
619                break
620        else:
621            raise RuntimeError("Fit function is not valid.")
622
623        n_parms = i
624
625    if not silent:
626        print('Fit with', n_parms, 'parameter' + 's' * (n_parms > 1))
627
628    x_f = np.vectorize(lambda o: o.value)(x)
629    dx_f = np.vectorize(lambda o: o.dvalue)(x)
630    y_f = np.array([o.value for o in y])
631    dy_f = np.array([o.dvalue for o in y])
632
633    if np.any(np.asarray(dx_f) <= 0.0):
634        raise Exception('No x errors available, run the gamma method first.')
635
636    if np.any(np.asarray(dy_f) <= 0.0):
637        raise Exception('No y errors available, run the gamma method first.')
638
639    if 'initial_guess' in kwargs:
640        x0 = np.asarray(kwargs.get('initial_guess'), dtype=np.float64)
641        if len(x0) != n_parms:
642            raise Exception(f'Initial guess does not have the correct length: {len(x0)} vs. {n_parms}')
643    else:
644        x0 = np.ones(n_parms, dtype=np.float64)
645
646    # odrpack expects f(x, beta), but pyerrors convention is f(beta, x)
647    def wrapped_func(x, beta):
648        return func(beta, x)
649
650    out = odr_fit(
651        wrapped_func,
652        np.asarray(x_f, dtype=np.float64),
653        np.asarray(y_f, dtype=np.float64),
654        beta0=x0,
655        weight_x=1.0 / np.asarray(dx_f, dtype=np.float64) ** 2,
656        weight_y=1.0 / np.asarray(dy_f, dtype=np.float64) ** 2,
657        partol=np.finfo(np.float64).eps,
658        task='explicit-ODR',
659        diff_scheme='central'
660    )
661
662    output.residual_variance = out.res_var
663
664    output.method = 'ODR'
665
666    output.message = out.stopreason
667
668    output.xplus = out.xplusd
669
670    if not silent:
671        print('Method: ODR')
672        print(out.stopreason)
673        print('Residual variance:', output.residual_variance)
674
675    if not out.success:
676        # ODRPACK95 info code structure (see User Guide §4):
677        #   info % 10        -> convergence: 1=sum-of-sq, 2=param, 3=both
678        #   info // 10 % 10  -> 1 = problem not full rank at solution
679        convergence_status = out.info % 10
680        rank_deficient = (out.info // 10 % 10) == 1
681
682        if convergence_status in [1, 2, 3] and rank_deficient:
683            warnings.warn(
684                f"ODR fit is rank deficient (irank={out.irank}, inv_condnum={out.inv_condnum:.2e}). "
685                "This may indicate a vanishing chi-squared (n_obs == n_parms). "
686                "Results may be unreliable.",
687                RuntimeWarning, stacklevel=2
688            )
689        else:
690            raise Exception('The minimization procedure did not converge.')
691
692    m = x_f.size
693
694    def odr_chisquare(p):
695        model = func(p[:n_parms], p[n_parms:].reshape(x_shape))
696        chisq = anp.sum(((y_f - model) / dy_f) ** 2) + anp.sum(((x_f - p[n_parms:].reshape(x_shape)) / dx_f) ** 2)
697        return chisq
698
699    if kwargs.get('expected_chisquare') is True:
700        W = np.diag(1 / np.asarray(np.concatenate((dy_f.ravel(), dx_f.ravel()))))
701
702        if kwargs.get('covariance') is not None:
703            cov = kwargs.get('covariance')
704        else:
705            cov = covariance(np.concatenate((y, x.ravel())))
706
707        number_of_x_parameters = int(m / x_f.shape[-1])
708
709        old_jac = jacobian(func)(out.beta, out.xplusd)
710        fused_row1 = np.concatenate((old_jac, np.concatenate((number_of_x_parameters * [np.zeros(old_jac.shape)]), axis=0)))
711        fused_row2 = np.concatenate((jacobian(lambda x, y: func(y, x))(out.xplusd, out.beta).reshape(x_f.shape[-1], x_f.shape[-1] * number_of_x_parameters), np.identity(number_of_x_parameters * old_jac.shape[0])))
712        new_jac = np.concatenate((fused_row1, fused_row2), axis=1)
713
714        A = W @ new_jac
715        P_phi = A @ np.linalg.pinv(A.T @ A) @ A.T
716        expected_chisquare = np.trace((np.identity(P_phi.shape[0]) - P_phi) @ W @ cov @ W)
717        if expected_chisquare <= 0.0:
718            warnings.warn("Negative expected_chisquare.", RuntimeWarning, stacklevel=2)
719            expected_chisquare = np.abs(expected_chisquare)
720        output.chisquare_by_expected_chisquare = odr_chisquare(np.concatenate((out.beta, out.xplusd.ravel()))) / expected_chisquare
721        if not silent:
722            print('chisquare/expected_chisquare:',
723                  output.chisquare_by_expected_chisquare)
724
725    fitp = out.beta
726    try:
727        hess = hessian(odr_chisquare)(np.concatenate((fitp, out.xplusd.ravel())))
728    except (TypeError, ValueError, np.linalg.LinAlgError):
729        raise Exception("It is required to use autograd.numpy instead of numpy within fit functions, see the documentation for details.") from None
730
731    def odr_chisquare_compact_x(d):
732        model = func(d[:n_parms], d[n_parms:n_parms + m].reshape(x_shape))
733        chisq = anp.sum(((y_f - model) / dy_f) ** 2) + anp.sum(((d[n_parms + m:].reshape(x_shape) - d[n_parms:n_parms + m].reshape(x_shape)) / dx_f) ** 2)
734        return chisq
735
736    jac_jac_x = hessian(odr_chisquare_compact_x)(np.concatenate((fitp, out.xplusd.ravel(), x_f.ravel())))
737
738    # Compute hess^{-1} @ jac_jac_x[:n_parms + m, n_parms + m:] using LAPACK dgesv
739    try:
740        deriv_x = -scipy.linalg.solve(hess, jac_jac_x[:n_parms + m, n_parms + m:])
741    except np.linalg.LinAlgError as err:
742        raise Exception("Cannot invert hessian matrix.") from err
743
744    def odr_chisquare_compact_y(d):
745        model = func(d[:n_parms], d[n_parms:n_parms + m].reshape(x_shape))
746        chisq = anp.sum(((d[n_parms + m:] - model) / dy_f) ** 2) + anp.sum(((x_f - d[n_parms:n_parms + m].reshape(x_shape)) / dx_f) ** 2)
747        return chisq
748
749    jac_jac_y = hessian(odr_chisquare_compact_y)(np.concatenate((fitp, out.xplusd.ravel(), y_f)))
750
751    # Compute hess^{-1} @ jac_jac_y[:n_parms + m, n_parms + m:] using LAPACK dgesv
752    try:
753        deriv_y = -scipy.linalg.solve(hess, jac_jac_y[:n_parms + m, n_parms + m:])
754    except np.linalg.LinAlgError as err:
755        raise Exception("Cannot invert hessian matrix.") from err
756
757    result = []
758    for i in range(n_parms):
759        result.append(derived_observable(lambda my_var, i=i, **kwargs: (my_var[0] + np.finfo(np.float64).eps) / (x.ravel()[0].value + np.finfo(np.float64).eps) * out.beta[i], list(x.ravel()) + list(y), man_grad=list(deriv_x[i]) + list(deriv_y[i])))
760
761    output.fit_parameters = result
762
763    output.odr_chisquare = odr_chisquare(np.concatenate((out.beta, out.xplusd.ravel())))
764    output.dof = x.shape[-1] - n_parms
765    output.p_value = 1 - scipy.stats.chi2.cdf(output.odr_chisquare, output.dof)
766
767    return output

Performs a non-linear fit to y = func(x) and returns a list of Obs corresponding to the fit parameters.

Parameters
  • x (list): list of Obs, or a tuple of lists of Obs
  • y (list): list of Obs. The dvalues of the Obs are used as x- and yerror for the fit.
  • func (object): func has to be of the form

    import autograd.numpy as anp
    
    def func(a, x):
        return a[0] + a[1] * x + a[2] * anp.sinh(x)
    

    For multiple x values func can be of the form

    def func(a, x):
        (x1, x2) = x
        return a[0] * x1 ** 2 + a[1] * x2
    

    It is important that all numpy functions refer to autograd.numpy, otherwise the differentiation will not work.

  • silent (bool, optional): If True all output to the console is omitted (default False).
  • initial_guess (list): can provide an initial guess for the input parameters. Relevant for non-linear fits with many parameters.
  • expected_chisquare (bool): If True prints the expected chisquare which is corrected by effects caused by correlated input data. This can take a while as the full correlation matrix has to be calculated (default False).
  • num_grad (bool): Use numerical differentiation instead of automatic differentiation to perform the error propagation (default False).
  • n_parms (int, optional): Number of fit parameters. Overrides automatic detection of parameter count. Useful when autodetection fails. Must match the length of initial_guess (if provided).
Notes

Based on the odrpack orthogonal distance regression library.

Returns
  • output (Fit_result): Parameters and information on the fitted result.
def fit_lin(x, y, **kwargs):
770def fit_lin(x, y, **kwargs):
771    """Performs a linear fit to y = n + m * x and returns two Obs n, m.
772
773    Parameters
774    ----------
775    x : list
776        Can either be a list of floats in which case no xerror is assumed, or
777        a list of Obs, where the dvalues of the Obs are used as xerror for the fit.
778    y : list
779        List of Obs, the dvalues of the Obs are used as yerror for the fit.
780
781    Returns
782    -------
783    fit_parameters : list[Obs]
784        LIist of fitted observables.
785    """
786
787    def f(a, x):
788        y = a[0] + a[1] * x
789        return y
790
791    if all(isinstance(n, Obs) for n in x):
792        out = total_least_squares(x, y, f, **kwargs)
793        return out.fit_parameters
794    elif all(isinstance(n, float) or isinstance(n, int) for n in x) or isinstance(x, np.ndarray):
795        out = least_squares(x, y, f, **kwargs)
796        return out.fit_parameters
797    else:
798        raise TypeError('Unsupported types for x')

Performs a linear fit to y = n + m * x and returns two Obs n, m.

Parameters
  • x (list): Can either be a list of floats in which case no xerror is assumed, or a list of Obs, where the dvalues of the Obs are used as xerror for the fit.
  • y (list): List of Obs, the dvalues of the Obs are used as yerror for the fit.
Returns
  • fit_parameters (list[Obs]): LIist of fitted observables.
def qqplot(x, o_y, func, p, title=''):
801def qqplot(x, o_y, func, p, title=""):
802    """Generates a quantile-quantile plot of the fit result which can be used to
803       check if the residuals of the fit are gaussian distributed.
804
805    Returns
806    -------
807    None
808    """
809
810    residuals = []
811    for i_x, i_y in zip(x, o_y, strict=True):
812        residuals.append((i_y - func(p, i_x)) / i_y.dvalue)
813    residuals = sorted(residuals)
814    my_y = [o.value for o in residuals]
815    probplot = scipy.stats.probplot(my_y)
816    my_x = probplot[0][0]
817    plt.figure(figsize=(8, 8 / 1.618))
818    plt.errorbar(my_x, my_y, fmt='o')
819    fit_start = my_x[0]
820    fit_stop = my_x[-1]
821    samples = np.arange(fit_start, fit_stop, 0.01)
822    plt.plot(samples, samples, 'k--', zorder=11, label='Standard normal distribution')
823    plt.plot(samples, probplot[1][0] * samples + probplot[1][1], zorder=10, label='Least squares fit, r=' + str(np.around(probplot[1][2], 3)), marker='', ls='-')
824
825    plt.xlabel('Theoretical quantiles')
826    plt.ylabel('Ordered Values')
827    plt.legend(title=title)
828    plt.draw()

Generates a quantile-quantile plot of the fit result which can be used to check if the residuals of the fit are gaussian distributed.

Returns
  • None
def residual_plot(x, y, func, fit_res, title=''):
831def residual_plot(x, y, func, fit_res, title=""):
832    """Generates a plot which compares the fit to the data and displays the corresponding residuals
833
834    For uncorrelated data the residuals are expected to be distributed ~N(0,1).
835
836    Returns
837    -------
838    None
839    """
840    sorted_x = sorted(x)
841    xstart = sorted_x[0] - 0.5 * (sorted_x[1] - sorted_x[0])
842    xstop = sorted_x[-1] + 0.5 * (sorted_x[-1] - sorted_x[-2])
843    x_samples = np.arange(xstart, xstop + 0.01, 0.01)
844
845    plt.figure(figsize=(8, 8 / 1.618))
846    gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1], wspace=0.0, hspace=0.0)
847    ax0 = plt.subplot(gs[0])
848    ax0.errorbar(x, [o.value for o in y], yerr=[o.dvalue for o in y], ls='none', fmt='o', capsize=3, markersize=5, label='Data')
849    ax0.plot(x_samples, func([o.value for o in fit_res], x_samples), label='Fit', zorder=10, ls='-', ms=0)
850    ax0.set_xticklabels([])
851    ax0.set_xlim([xstart, xstop])
852    ax0.set_xticklabels([])
853    ax0.legend(title=title)
854
855    residuals = (np.asarray([o.value for o in y]) - func([o.value for o in fit_res], np.asarray(x))) / np.asarray([o.dvalue for o in y])
856    ax1 = plt.subplot(gs[1])
857    ax1.plot(x, residuals, 'ko', ls='none', markersize=5)
858    ax1.tick_params(direction='out')
859    ax1.tick_params(axis="x", bottom=True, top=True, labelbottom=True)
860    ax1.axhline(y=0.0, ls='--', color='k', marker=" ")
861    ax1.fill_between(x_samples, -1.0, 1.0, alpha=0.1, facecolor='k')
862    ax1.set_xlim([xstart, xstop])
863    ax1.set_ylabel('Residuals')
864    plt.subplots_adjust(wspace=None, hspace=None)
865    plt.draw()

Generates a plot which compares the fit to the data and displays the corresponding residuals

For uncorrelated data the residuals are expected to be distributed ~N(0,1).

Returns
  • None
def error_band(x, func, beta):
868def error_band(x, func, beta):
869    """Calculate the error band for an array of sample values x, for given fit function func with optimized parameters beta.
870
871    Returns
872    -------
873    err : np.array(Obs)
874        Error band for an array of sample values x
875    """
876    cov = covariance(beta)
877    if np.any(np.abs(cov - cov.T) > 1000 * np.finfo(np.float64).eps):
878        warnings.warn("Covariance matrix is not symmetric within floating point precision", RuntimeWarning, stacklevel=2)
879
880    deriv = []
881    for item in x:
882        deriv.append(np.array(egrad(func)([o.value for o in beta], item)))
883
884    err = []
885    for i, _item in enumerate(x):
886        err.append(np.sqrt(deriv[i] @ cov @ deriv[i]))
887    err = np.array(err)
888
889    return err

Calculate the error band for an array of sample values x, for given fit function func with optimized parameters beta.

Returns
  • err (np.array(Obs)): Error band for an array of sample values x
def ks_test(objects=None):
892def ks_test(objects=None):
893    """Performs a Kolmogorov–Smirnov test for the p-values of all fit object.
894
895    Parameters
896    ----------
897    objects : list
898        List of fit results to include in the analysis (optional).
899
900    Returns
901    -------
902    None
903    """
904
905    if objects is None:
906        obs_list = []
907        for obj in gc.get_objects():
908            if isinstance(obj, Fit_result):
909                obs_list.append(obj)
910    else:
911        obs_list = objects
912
913    p_values = [o.p_value for o in obs_list]
914
915    bins = len(p_values)
916    x = np.arange(0, 1.001, 0.001)
917    plt.plot(x, x, 'k', zorder=1)
918    plt.xlim(0, 1)
919    plt.ylim(0, 1)
920    plt.xlabel('p-value')
921    plt.ylabel('Cumulative probability')
922    plt.title(str(bins) + ' p-values')
923
924    n = np.arange(1, bins + 1) / np.float64(bins)
925    Xs = np.sort(p_values)
926    plt.step(Xs, n)
927    diffs = n - Xs
928    loc_max_diff = np.argmax(np.abs(diffs))
929    loc = Xs[loc_max_diff]
930    plt.annotate('', xy=(loc, loc), xytext=(loc, loc + diffs[loc_max_diff]), arrowprops=dict(arrowstyle='<->', shrinkA=0, shrinkB=0))
931    plt.draw()
932
933    print(scipy.stats.kstest(p_values, 'uniform'))

Performs a Kolmogorov–Smirnov test for the p-values of all fit object.

Parameters
  • objects (list): List of fit results to include in the analysis (optional).
Returns
  • None