"""Functions to display fitting results and confidence intervals.""" from math import log10 import re import numpy as np try: import numdifftools # noqa: F401 HAS_NUMDIFFTOOLS = True except ImportError: HAS_NUMDIFFTOOLS = False def alphanumeric_sort(s, _nsre=re.compile('([0-9]+)')): """Sort alphanumeric string.""" return [int(text) if text.isdigit() else text.lower() for text in re.split(_nsre, s)] def getfloat_attr(obj, attr, length=11): """Format an attribute of an object for printing.""" val = getattr(obj, attr, None) if val is None: return 'unknown' if isinstance(val, int): return f'{val}' if isinstance(val, float): return gformat(val, length=length).strip() return repr(val) def gformat(val, length=11): """Format a number with '%g'-like format. Except that: a) the length of the output string will be of the requested length. b) positive numbers will have a leading blank. b) the precision will be as high as possible. c) trailing zeros will not be trimmed. The precision will typically be ``length-7``. Parameters ---------- val : float Value to be formatted. length : int, optional Length of output string (default is 11). Returns ------- str String of specified length. Notes ------ Positive values will have leading blank. """ if val is None or isinstance(val, bool): return f'{repr(val):>{length}s}' try: expon = int(log10(abs(val))) except (OverflowError, ValueError): expon = 0 except TypeError: return f'{repr(val):>{length}s}' length = max(length, 7) form = 'e' prec = length - 7 if abs(expon) > 99: prec -= 1 elif ((expon > 0 and expon < (prec+4)) or (expon <= 0 and -expon < (prec-1))): form = 'f' prec += 4 if expon > 0: prec -= expon return f'{val:{length}.{prec}{form}}' def fit_report(inpars, modelpars=None, show_correl=True, min_correl=0.1, sort_pars=False, correl_mode='list'): """Generate a report of the fitting results. The report contains the best-fit values for the parameters and their uncertainties and correlations. Parameters ---------- inpars : Parameters Input Parameters from fit or MinimizerResult returned from a fit. modelpars : Parameters, optional Known Model Parameters. show_correl : bool, optional Whether to show list of sorted correlations (default is True). min_correl : float, optional Smallest correlation in absolute value to show (default is 0.1). sort_pars : bool or callable, optional Whether to show parameter names sorted in alphanumerical order. If False (default), then the parameters will be listed in the order they were added to the Parameters dictionary. If callable, then this (one argument) function is used to extract a comparison key from each list element. correl_mode : {'list', table'} str, optional Mode for how to show correlations. Can be either 'list' (default) to show a sorted (if ``sort_pars`` is True) list of correlation values, or 'table' to show a complete, formatted table of correlations. Returns ------- str Multi-line text of fit report. """ from .parameter import Parameters if isinstance(inpars, Parameters): result, params = None, inpars if hasattr(inpars, 'params'): result = inpars params = inpars.params if sort_pars: if callable(sort_pars): key = sort_pars else: key = alphanumeric_sort parnames = sorted(params, key=key) else: # dict.keys() returns a KeysView in py3, and they're indexed # further down parnames = list(params.keys()) buff = [] add = buff.append namelen = max(len(n) for n in parnames) if result is not None: add("[[Fit Statistics]]") add(f" # fitting method = {result.method}") add(f" # function evals = {getfloat_attr(result, 'nfev')}") add(f" # data points = {getfloat_attr(result, 'ndata')}") add(f" # variables = {getfloat_attr(result, 'nvarys')}") add(f" chi-square = {getfloat_attr(result, 'chisqr')}") add(f" reduced chi-square = {getfloat_attr(result, 'redchi')}") add(f" Akaike info crit = {getfloat_attr(result, 'aic')}") add(f" Bayesian info crit = {getfloat_attr(result, 'bic')}") if hasattr(result, 'rsquared'): add(f" R-squared = {getfloat_attr(result, 'rsquared')}") if not result.errorbars: add("## Warning: uncertainties could not be estimated:") if result.method in ('leastsq', 'least_squares') or HAS_NUMDIFFTOOLS: parnames_varying = [par for par in result.params if result.params[par].vary] for name in parnames_varying: par = params[name] space = ' '*(namelen-len(name)) if par.init_value and np.allclose(par.value, par.init_value): add(f' {name}:{space} at initial value') if (np.allclose(par.value, par.min) or np.allclose(par.value, par.max)): add(f' {name}:{space} at boundary') else: add(" this fitting method does not natively calculate uncertainties") add(" and numdifftools is not installed for lmfit to do this. Use") add(" `pip install numdifftools` for lmfit to estimate uncertainties") add(" with this fitting method.") add("[[Variables]]") for name in parnames: par = params[name] space = ' '*(namelen-len(name)) nout = f"{name}:{space}" inval = '(init = ?)' if par.init_value is not None: inval = f'(init = {par.init_value:.7g})' if modelpars is not None and name in modelpars: inval = f'{inval}, model_value = {modelpars[name].value:.7g}' try: sval = gformat(par.value) except (TypeError, ValueError): sval = ' Non Numeric Value?' if par.stderr is not None: serr = gformat(par.stderr) try: spercent = f'({abs(par.stderr/par.value):.2%})' except ZeroDivisionError: spercent = '' sval = f'{sval} +/-{serr} {spercent}' if par.vary: add(f" {nout} {sval} {inval}") elif par.expr is not None: add(f" {nout} {sval} == '{par.expr}'") else: add(f" {nout} {par.value: .7g} (fixed)") if show_correl and correl_mode.startswith('tab'): add('[[Correlations]] ') for line in correl_table(params).split('\n'): buff.append(' %s' % line) elif show_correl: correls = {} for i, name in enumerate(parnames): par = params[name] if not par.vary: continue if hasattr(par, 'correl') and par.correl is not None: for name2 in parnames[i+1:]: if (name != name2 and name2 in par.correl and abs(par.correl[name2]) > min_correl): correls[f"{name}, {name2}"] = par.correl[name2] sort_correl = sorted(correls.items(), key=lambda it: abs(it[1])) sort_correl.reverse() if len(sort_correl) > 0: add('[[Correlations]] (unreported correlations are < ' f'{min_correl:.3f})') maxlen = max(len(k) for k in list(correls.keys())) for name, val in sort_correl: lspace = max(0, maxlen - len(name)) add(f" C({name}){(' '*30)[:lspace]} = {val:+.4f}") return '\n'.join(buff) def lcol(s, cat='td'): "html left column" return f"<{cat} style='text-align:left'>{s}{cat}>" def rcol(s, cat='td'): "html right column" return f"<{cat} style='text-align:right'>{s}{cat}>" def trow(columns, cat='td'): "html row" nlast = len(columns)-1 rows = [] for i, col in enumerate(columns): cform = rcol if i == nlast else lcol rows.append(cform(col, cat=cat)) return rows def fitreport_html_table(result, show_correl=True, min_correl=0.1): """Generate a report of the fitting result as an HTML table. Parameters ---------- result : MinimizerResult or ModelResult Object containing the optimized parameters and several goodness-of-fit statistics. show_correl : bool, optional Whether to show list of sorted correlations (default is True). min_correl : float, optional Smallest correlation in absolute value to show (default is 0.1). Returns ------- str Multi-line HTML code of fit report. """ html = [] add = html.append def stat_row(label, val, val2=None, cat='td'): if val2 is None: rows = trow([label, val], cat=cat) else: rows = trow([label, val, val2], cat=cat) add(f"