Source code for Stoner.analysis.fitting.classes

# -*- coding: utf-8 -*-
"""Classes to support data fitting."""

from copy import deepcopy as copy
from dataclasses import dataclass, field
from inspect import getfullargspec, isclass
from typing import Optional, Union

import lmfit as lmfit_mod
import numpy as np
from lmfit.model import Model
from scipy.odr import Model as odrModel

from ...compat import get_func_params
from ...tools import AttributeStore

_lmfit = True


[docs] class ODR_Model(odrModel): """A wrapper for converting lmfit models to odr models.""" def __init__(self, *args, **kwargs): """Initialise with lmfit_mod.Models.Model or callable.""" meta = kwargs.pop("meta", {}) kwargs = copy(kwargs) for n in list(kwargs.keys()): if n in ["replace", "header", "result", "output", "residuals", "prefix"]: del kwargs[n] p0 = kwargs.pop("p0", kwargs.pop("estimate", None)) if args: args = list(args) model = args.pop(0) else: raise RuntimeError("Need at least one argument to make a fitting model.") if isclass(model) and issubclass(model, Model): # Instantiate if only a class passed in model = model() if isclass(model) and issubclass(model, odrModel): model = model() if isinstance(model, Model): self.model = model self.func = model.func def modelfunc(beta, x, **kwargs): return self.func(x, *beta, **kwargs) meta["param_names"] = self.model.param_names meta["param_hints"] = self.model.param_hints meta["name"] = type(self.model).__name__ elif isinstance(model, odrModel): self.model = model meta.update(model.meta) meta["param_names"] = model.meta.pop("param_names", [f"Param_{ix}" for ix, p in enumerate(p0)]) meta["name"] = model.fcn.__name__ modelfunc = model.fcn self.model.meta.update(meta) elif callable(model): self.model = None meta["name"] = model.__name__ arguments = getfullargspec(model)[0] # pylint: disable=W1505 meta["param_names"] = list(arguments[1:]) meta["param_hints"] = {x: {"value": 1.0} for x in arguments[1:]} # print(arguments,carargs,jeywords,defaults) self.func = model def modelfunc(beta, x, **_): # pylint: disable=E0102 """Warapper for model function.""" return model(x, *beta) meta["__name__"] = meta["name"] else: raise ValueError( "".join( [ f"Cannot construct a model instance from a {model} - ", "need a callable, lmfit_mod.Model or scipy.odr.Model", ] ) ) if not isinstance(p0, lmfit_mod.Parameters): # This can happen if we are creating an ODR_Model in advance. tmp_model = AttributeStore(meta) p0 = _prep_lmfit_p0(tmp_model, None, None, p0, kwargs)[0] p_new = [] meta["params"] = copy(p0) for p in p0.values(): p_new.append(p.value) p0 = p_new kwargs["estimate"] = p0 kwargs["meta"] = meta super().__init__(modelfunc, *args, **kwargs) @property def p0(self): """Convert an estimate attribute as p0.""" return getattr(self, "estimate", None) @property def param_names(self): """Convert the meta parameter key param_names to an attribute.""" return self.meta["param_names"]
class MimizerAdaptor: """Work with an lmfit_mod.Model or generic callable to use with scipy.optimize global minimization functions. The :pymod:`scipy.optimize` module's minimizers generally expect functions which take an array like parameter space variable and then other arguments. This class will produce a suitable wrapper function and bounds variables from information int he lmfit_mod.Model. """ def __init__(self, model, *args, **kwargs): # pylint: disable=unused-argument """Prepare the wrapper from the minimuzer. Args: modelower (lmfit): The model that has been fitted. *args (tuple): Positional parameters to initialise class. Keyword Arguments: params (lmfit:parameter or dict): Parameters used to fit model. **kwargs (dict): Keyword arguments to initialise the result object/. Raises: RuntimeError: Fails if a *params* Parameter does not supply a fitted value. """ self.func = model.func hints = kwargs.pop("params") p0 = [] upper = [] lower = [] for name, hint in hints.items(): if not isinstance(hint, lmfit_mod.Parameter): hint = lmfit_mod.Parameter(**hint) if not hasattr(hint, "value"): raise RuntimeError(f"At the very least we need a starting value for the {name} parameter") v = hint.value p0.append(v) limits = [v * 10, v * 0.1] hint_upper = getattr(hint, "max", max(limits)) hint_lower = getattr(hint, "min", min(limits)) upper.append(hint_upper if not np.isinf(hint_upper) else max(limits)) lower.append(hint_lower if not np.isinf(hint_lower) else min(limits)) self.p0 = p0 self.bounds = list(zip(lower, upper)) def wrapper(beta, x, y, sigma, *args): """Calculate a least-squares goodness from the model functiuon.""" beta = tuple(beta) + tuple(args) if sigma is None: sigma = np.ones_like(x) elif isinstance(sigma, float): sigma = np.ones_like(x) * sigma sigma = sigma / sigma.sum() # normalise uncertainties sigma += np.finfo(float).eps weights = 1.0 / sigma**2 variance = ((y - self.func(x, *beta)) ** 2) * weights return np.sum(variance) / (len(x) - len(beta)) self.minimize_func = wrapper class _Curve_Fit_Result: """Represent a result from fitting using :py:func:`scipy.optimize.curve_fit` as a class to make handling easier. """ def __init__(self): """Store the results of the curve fit full_output fit. Args: popt (1D array): Optimal parameters for fit. pcov (2D array): Variance-co-variance matrix. infodict (dict): Additional information from curve_fit. mesg (str): Descriptive information from curve_fit. ier (int): Numerical error message. """ self._mapping = {} self.func = lambda *args: None self.f_name = None self.args = [] self.kwargs = {} self.p0 = None self._residual_vals = None self.f_name = None self._infodict = {} self._results = {} self.infodict = {k: None for k in ["mfev", "fvec", "fjac", "ipvt", "qtf"]} self.results = {k: None for k in ["pop", "perr", "pcov", "mesg", "ier", "chisq", "nfree"]} self.data = None self.labels = None self.units = None # Following properties used to return desired information @property def name(self): """Name of the model fitted.""" return self.func.__name__ @property def dict(self): """Optimal parameters and errors as a Dictionary.""" ret = {} for p, v, e in zip(self.params, self.popt, self.perr): ret[p] = v ret["d_{}", format(p)] = e ret["chi-square"] = self.chisqr ret["red. chi-sqr"] = self.redchi ret["nfev"] = self.nfev return ret @property def full(self): """Return the same as :py:attr:`_curve_fit_result.row`.""" return self, self.row @property def row(self): """Optimal parameters and errors as a single row.""" ret = np.zeros(len(self.popt) * 2 + 1) ret[0:-2:2] = self.popt ret[1::2] = self.perr ret[-1] = self.chisq return ret @property def infodict(self): """Wrapper infodict.""" return self._infodict @infodict.setter def infodict(self, value): """Wrapper for setting infodict and subkeys.""" self._infodict = value if isinstance(value, dict): for k in value: self._mapping[k] = "_infodict" @property def results(self): """Wrapper for a results dictionary.""" return self._results @results.setter def results(self, value): """Wrapper for setting results dictionary and nting keys.""" self._results = value if isinstance(value, dict): for k in value: self._mapping[k] = "_results" @property def fit(self): """Copy of the fit report and optimal parameters and covariance.""" return (self.popt, self.pcov) @property def fit_values(self): """Return the fit values if x data is set.""" if self.data is None or self.func is None or self.popt is None: raise ValueError( "Need to have some x-data, the fitting functions and optimal parameters before calculating fit" ) return self.func(self.data.data[:, self.settings.columns.xcol], *self.popt) @property def perr(self): """Return standar error from covariance matrix.""" if "perr" in self.results and self.results["perr"] is not None: return self.results["perr"] return np.sqrt(np.diag(self.pcov)) @property def report(self): """Copy of the fit report.""" return self @property def residual_vals(self): """If we have residual values, return the,.""" if self._residual_vals is None and self.data is not None: self._residual_vals = self.data - self.fit_values return getattr(self, "_residual_vals", None) @residual_vals.setter def residual_vals(self, values): """Update residual values.""" self._residual_vals = values @property def N(self): """Return th number of data points in dataset.""" return len(self.data) @property def n_p(self): """Return the number of parameters in model.""" return len(self.popt) @property def redchi(self): r"""Reduced $\chi^2$ Statistic.""" return self.chisq @property def chisqr(self): r"""$\chi^2$ Statistic.""" return self.chisq * (self.N - self.n_p) @property def aic(self): """Return the Akaike Information Criterion statistic.""" return self.N * np.log(self.chisqr / self.N) + 2 * self.n_p @property def bic(self): """Return the Bayesian Information Criterion statistic.""" return self.N * np.log(self.chisqr / self.N) + np.log(self.N) * self.n_p @property def params(self): """List the parameter class objects.""" return get_func_params(self.func) def __dir__(self): """Extend the attribute directory.""" return super().__dir__() + list(self._mapping.keys()) def __getattr__(self, name): """Defer to using things we're tracking in the mapping.""" try: return super().__getattr__(name) except AttributeError: pass if name in self._mapping: return getattr(self, self._mapping[name]).get(name, None) raise AttributeError(f"{name} is not an attribute or {self.__class__.__name__}.") def __setattr__(self, name, value): """Pass through if an atrbute is already set.""" if name != "_mapping" and name in self._mapping: getattr(self, self._mapping[name])[name] = value return super().__setattr__(name, value) def fit_report(self): """Create a Fit report like lmfit does.""" template = f"""[[ Model ]] {self.name} [[ Fit Statistics ]] # function evals = {self.nfev} # data points = {self.N} # variables = {self.n_p} chi-square = {self.chisqr} reduced chi-square = {self.redchi} Akaike info crit = {self.aic} Bayesian info crit = {self.bic} [[ Variables ]]\n""" for p, v, e, p0 in zip(self.params, self.popt, self.perr, self.p0): template += f"\t{p}: {v} +/- {e} ({(e * 100 / v):.3f}%) (init {p0})\n" template += "[[Correlations]] (unreported correlations are < 0.100)\n" for i, p in enumerate(self.params): for j in range(i + 1, len(self.params)): if np.abs(self.pcov[i, j]) > 0.1: template += f"\t({p},{list(self.params)[j]})\t\t={self.pcov[i, j]:.3f}" return template def add_metadata(self, datafile): """Adds metadata keys to datafile based on this fit report. Args: datafile (Data): Data instance to add metadata keys to. Returns: (Data): Updated data file. """ f_name = self.f_name for val, err, name, label, unit in zip(self.popt, self.perr, self.args, self.labels, self.units): datafile[f"{f_name}:{name}"] = val datafile[f"{f_name}:{name} err"] = err datafile[f"{f_name}:{name} label"] = datafile.metadata.get(f"{f_name}:{name} label", label) datafile[f"{f_name}:{name} unit"] = datafile.metadata.get(f"{f_name}:{name} unit", unit) if self.nfev is not None: datafile[f"{f_name}:nfev"] = self.nfev return datafile @dataclass class _Curve_Fit_Output: """Dataclass for gathering together information about the output from a curve fitting operation.""" prefix: str = "" columns: AttributeStore = field(default_factory=dict) result: Union[bool, str, None] = None replace: bool = False header: Optional[str] = None residuals: bool = False asrow: bool = False output: str = "row" scale_covar: bool = True nan_policy: str = "raise" def _prep_lmfit_model(model, kwargs): """Prepare an lmfit model instance. Arguments: model (lmfit Model class or instance, or callable): the model to be fitted to the data. p0 (iterable or floats): The initial values of the fitting parameters. kwargs (dict):Other keyword arguments passed to the fitting function Returns: model,p0, prefix (lmfit_mod.Model instance, iterable, str) Converts the model parameter into an instance of lmfit_mod.Model - either by instantiating the class or wrapping a callable into an lmfit_mod.Model class and establishes a prefix string from the model if not provided in the keyword arguments. """ if Model is None: # Will be the case if lmfit is not imported. raise RuntimeError( """To use the lmfit function you need to be able to import the lmfit module\n Try pip install lmfit\nat a command prompt.""" ) # Enure that model is an instance of an lmfit_mod.Model() class if isinstance(model, Model): pass elif isclass(model) and issubclass(model, Model): model = model(nan_policy="propagate") elif callable(model): model = Model(model) else: raise TypeError(f"{model} must be an instance of lmfit_mod.Model or a cllable function!") # Nprmalise p0 to be lmfit_mod.Parameters # Get a default prefix for the model prefix = str(kwargs.pop("prefix", type(model).__name__)) return model, prefix def _prep_lmfit_p0(model, ydata, xdata, p0, kwargs): """Prepare the initial start vector for an lmfit. Arguments: model (lmfit_mod.Model instance): model to fit with ydata,xdata (array): y and x data ppoints for fitting p0 (iterable of float): Existing p0 vector if defined kwargs (dict): Other keyword arguments for the lmfit method. Returns: p0,single_fit (iterable of floats, bool): The revised initial starting vector and whether this is a single fit operation. """ single_fit = True if p0 is None: # First guess the p0 values using the model if isinstance(model, odrModel): p0 = model.estimate else: for p_name in model.param_names: if p_name in kwargs: model.set_param_hint(p_name, value=kwargs.get(p_name)) try: p0 = model.guess(ydata[0], x=xdata) except Exception: # pylint: disable=W0703 # Don't be fussy here about the potential exceptions p0 = lmfit_mod.Parameters() for p_name in model.param_names: if p_name in kwargs: p0[p_name] = lmfit_mod.Parameter(name=p_name, value=kwargs.get(p_name)) single_fit = True if callable(p0): p0 = p0(ydata, xdata) if isinstance(p0, (list, tuple)): p0 = np.array(p0) if isinstance(p0, np.ndarray) and (p0.ndim == 1 or (p0.ndim == 2 and np.max(p0.shape) == p0.size)): single_fit = True p_new = lmfit_mod.Parameters() p0 = p0.ravel() for n, v in zip(model.param_names, p0): if hasattr(model, "param_hints"): hint = model.param_hints.get(n, {}) else: hint = {} hint["value"] = v hint["name"] = n p_new[n] = lmfit_mod.Parameter(**hint) p0 = p_new for p_name in model.param_names: if p_name in kwargs: p0[p_name] = lmfit_mod.Parameter(p_name, value=kwargs.pop(p_name)) elif isinstance(p0, np.ndarray) and p0.ndim == 2: # chi^2 mapping single_fit = False return p0, single_fit if not isinstance(p0, lmfit_mod.Parameters): raise RuntimeError(f"Unknown data type for initial guess vector p0: {type(p0)}") if set(p0.keys()) < set(model.param_names): raise RuntimeError( f"Missing some values from the initial guess vector p0: {set(model.param_names) - set(p0.keys())}" ) return p0, single_fit