Source code for pygimli.frameworks.methodManager

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""Method Manager.

Provide the end user interface for method (geophysical) dependent
modelling and inversion as well as data and model visualization.
"""

import numpy as np
import pygimli as pg

from pygimli.utils import prettyFloat as pf


[docs] def fit(funct, data, err=None, **kwargs): """Generic function fitter. Fit data to a given function. TODO ---- * Dictionary support for funct to submit user data.. Parameters ---------- funct: callable Function with the first argmument as data space, e.g., x, t, f, Nr. .. Any following arguments are the parameters to be fit. Except if a verbose flag if used. data: iterable (float) Data values err: iterable (float) [None] Data error values in %/100. Default is 1% if None are given. Other Parameters ---------------- *dataSpace*: iterable Keyword argument of the data space of len(data). The name need to fit the first argument of funct. Returns ------- model: array Fitted model parameter. response: array Model response. Example ------- >>> import pygimli as pg >>> >>> func = lambda t, a, b: a*np.exp(b*t) >>> t = np.linspace(1, 2, 20) >>> data = func(t, 1.1, 2.2) >>> model, response = pg.frameworks.fit(func, data, t=t) >>> print(pg.core.round(model, 1e-5)) 2 [1.1, 2.2] >>> _ = pg.plt.plot(t, data, 'o', label='data') >>> _ = pg.plt.plot(t, response, label='response') >>> _ = pg.plt.legend() """ if isinstance(data, list): data = np.array(data) if isinstance(err, (float, int)): err = np.full(len(data), err) mgr = ParameterInversionManager(funct, **kwargs) model = mgr.invert(data, err, **kwargs) return model, mgr.fw.response
# TG: harmonicFit does not really belong here as it is no curve fit # We should rather use a class Decomposition # Discuss .. rename to Framework or InversionFramework since he only manages # the union of Inversion/Modelling and RegionManager(later)
[docs] class MethodManager(object): """General manager to maintenance a measurement method. Method Manager are the interface to end-user interaction and can be seen as simple but complete application classes which manage all tasks of geophysical data processing. The method manager holds one instance of a forward operator and an appropriate inversion framework to handle modelling and data inversion. Method Manager also helps with data import and export, handle measurement data error estimation as well as model and data visualization. Attributes ---------- verbose: bool Give verbose output. debug: bool Give debug output. fop: :py:mod:`pygimli.frameworks.Modelling` Forward Operator instance .. knows the physics. fop is initialized by :py:mod:`pygimli.manager.MethodManager.initForwardOperator` and calls a valid :py:mod:`pygimli.manager.MethodManager.createForwardOperator` method in any derived classes. inv: :py:mod:`pygimli.frameworks.Inversion`. Inversion framework instance .. knows the reconstruction approach. The attribute inv is initialized by default but can be changed overwriting :py:mod:`pygimli.manager.MethodManager.initInversionFramework` """
[docs] def __init__(self, fop=None, fw=None, data=None, **kwargs): """Initialize the class with given fop, inv and data.""" self._fop = fop self._fw = fw # we hold our own copy of the data self._verbose = kwargs.pop('verbose', False) self._debug = kwargs.pop('debug', False) # The inversion framework self._initInversionFramework(verbose=self._verbose, debug=self._debug) # The forward operator is stored in self._fw self._initForwardOperator(verbose=self._verbose, **kwargs) self._data = None if data is not None: if isinstance(data, str): self.load(data) else: #self.data = data self.setData(data) # maybe obsolete self.figs = {} self.errIsAbsolute = False
def __hash__(self): """Create a hash for Method Manager.""" return pg.utils.strHash(str(type(self))) ^ hash(self.fop) @property def verbose(self): """Verbose output.""" return self._verbose @verbose.setter def verbose(self, v): self._verbose = v self.fw.verbose = self._verbose @property def debug(self): """Debug mode (extensive output).""" return self._debug @debug.setter def debug(self, v): self._debug = v self.fw.debug = self._debug @property def fw(self): """Inversion framework.""" return self._fw @property def fop(self): """Forward operator.""" return self.fw.fop @property def inv(self): """Inversion instance.""" return self.fw @property def model(self): """Inversion model.""" return self.fw.model
[docs] def reinitForwardOperator(self, **kwargs): """Reinitialize the forward operator. Sometimes it can be useful to reinitialize the forward operator. Keyword arguments will be forwarded to 'self.createForwardOperator'. """ self._initForwardOperator(**kwargs)
def _initForwardOperator(self, **kwargs): """Initialize or re-initialize the forward operator. Called once in the constructor to force the manager to create the necessary forward operator member. Can be recalled if you need to changed the mangers own forward operator object. If you want an own instance of a valid FOP call createForwardOperator. """ if self._fop is not None: fop = self._fop else: fop = self.createForwardOperator(**kwargs) if fop is None: pg.critical("It seems that createForwardOperator method " "does not return a valid forward operator.") if self.fw is not None: self.fw.reset() self.fw.setForwardOperator(fop) else: pg.critical("No inversion framework defined.")
[docs] def createForwardOperator(self, **kwargs): """Mandatory interface for derived classes. Here you need to specify which kind of forward operator FOP you want to use. This is called by any initForwardOperator() call. Parameters ---------- **kwargs Any arguments that are necessary for your FOP creation. Returns ------- Modelling Instance of any kind of :py:mod:`pygimli.framework.Modelling`. """ pg.critical("No forward operator defined, either give one or " "overwrite in derived class")
def _initInversionFramework(self, **kwargs): """Initialize or re-initialize the inversion framework. Called once in the constructor to force the manager to create the necessary Framework instance. """ self._fw = self.createInversionFramework(**kwargs) if self.fw is None: pg.critical("createInversionFramework does not return " "valid inversion framework.")
[docs] def createInversionFramework(self, **kwargs): """Create default Inversion framework. Derived classes may overwrite this method. Parameters ---------- **kwargs Any arguments that are necessary for your creation. Returns ------- Inversion Instance of any kind of :py:mod:`pygimli.framework.Inversion`. """ if self._fw is None: return pg.frameworks.Inversion(**kwargs) else: return self._fw
[docs] def load(self, fileName): """Load API, overwrite in derived classes.""" pg.critical('API, overwrite in derived classes', fileName)
[docs] def estimateError(self, data, errLevel=0.01, absError=None): # TODO check, rel or abs in return. """Estimate data error. Create an error of estimated measurement error. On default it returns an array of constant relative errors. More sophisticated error estimation should be done in specialized derived classes. Parameters ---------- data: iterable Data values for which the errors should be estimated. errLevel: float (0.01) Error level in percent/100 (i.e., 3% = 0.03). absError: float (None) Absolute error in the unit of the data. Returns ------- err: array Returning array of size len(data) """ if absError is not None: return absError + data * errLevel return np.ones(len(data)) * errLevel
[docs] def simulate(self, model, **kwargs): """Run a simulation aka the forward task.""" ra = self.fop.response(par=model) noiseLevel = kwargs.pop('noiseLevel', 0.0) if noiseLevel > 0: err = self.estimateError(ra, errLevel=noiseLevel) ra *= 1. + pg.randn(ra.size(), seed=kwargs.pop('seed', None)) * err return ra, err return ra
@property def data(self): return self._data @data.setter def data(self, d): return self.setData(d)
[docs] def setData(self, data): """Set data and distribute it to the forward operator.""" self._data = data self.applyData(self._data)
[docs] def applyData(self, data): """Pass the data to the forward operator.""" self.fop.data = data
[docs] def checkData(self, data): """Overwrite for special checks to return data values.""" # if self._dataToken == 'nan': # pg.critical('self._dataToken nan, should be set in class', self) # return data(self._dataToken) return data
def _ensureData(self, data): """Check data validity.""" if data is None: data = self.fw.dataVals vals = self.checkData(data) if vals is None: pg.critical("There are no data values.") if abs(min(vals)) < 1e-12: print(min(vals), max(vals)) pg.critical("There are data values equals 0.0") return vals
[docs] def checkError(self, err, dataVals=None): """Check and return relative error. By default we assume 'err' as relative values. Overwrite in derived class if needed. """ if isinstance(err, pg.DataContainer): if not err.haveData('err'): pg.error('Datacontainer have no "err" values. ' 'Fallback set to 0.01') return err['err'] return err
def _ensureError(self, err, dataVals=None): """Check error validity.""" if err is None: err = self.fw.errorVals vals = self.checkError(err, dataVals) if vals is None: pg.warn('No data error given, set Fallback set to 1%') vals = np.ones(len(dataVals)) * 0.01 try: if min(vals) <= 0: pg.critical("All error values need to be larger then 0. Either" " give and err argument or fill dataContainer " " with a valid 'err' ", min(vals), max(vals)) except ValueError: pg.critical("Can't estimate data error") return vals
[docs] def preRun(self, *args, **kwargs): """Fcn to be called just before the inversion run starts.""" pass
[docs] def postRun(self, *args, **kwargs): """Fcn to be called just after the inversion run.""" pass
[docs] def invert(self, data=None, err=None, **kwargs): """Invert the data. Invert the data by calling self.inv.run() with mandatory data and error values. TODO *need dataVals mandatory? what about already loaded data Parameters ---------- dataVals: iterable Data values to be inverted. errVals: iterable | float Error value for the given data. If errVals is float we assume this means to be a global relative error and force self.estimateError to be called. """ if data is not None: self.data = data else: data = self.data dataVals = self._ensureData(data) errVals = self._ensureError(err, dataVals) self.preRun(**kwargs) self.fw.run(dataVals, errVals, **kwargs) self.postRun(**kwargs) return self.fw.model
[docs] def showModel(self, model, ax=None, **kwargs): """Show a model. Draw model into a given axes or show inversion result from last run. Forwards on default to the self.fop.drawModel function of the modelling operator. If there is no function given, you have to override this method. Parameters ---------- ax: mpl axes Axes object to draw into. Create a new if its not given. model: iterable Model data to be draw. Returns ------- ax, cbar """ if ax is None: _, ax = pg.plt.subplots() ax, cBar = self.fop.drawModel(ax, model, **kwargs) return ax, cBar
[docs] def showData(self, data=None, ax=None, **kwargs): """Show the data. Draw data values into a given axes or show the data values from the last run. Forwards on default to the self.fop.drawData function of the modelling operator. If there is no given function given, you have to override this method. Parameters ---------- ax: mpl axes Axes object to draw into. Create a new if its not given. data: iterable | pg.DataContainer Data values to be draw. Returns ------- ax, cbar """ if ax is None: _, ax = pg.plt.subplots() if data is None: data = self.data self.fop.drawData(ax, data, **kwargs) return ax, None
[docs] def showResult(self, model=None, ax=None, **kwargs): """Show the last inversion result. TODO ---- DRY: decide showModel or showResult Parameters ---------- ax: mpl axes Axes object to draw into. Create a new if its not given. model: iterable [None] Model values to be draw. Default is self.model from the last run Returns ------- ax, cbar """ if model is None: model = self.model return self.showModel(model, ax=ax, **kwargs)
[docs] def showFit(self, ax=None, **kwargs): """Show the last inversion data and response.""" ax, cBar = self.showData(data=self.inv.dataVals, error=self.inv.errorVals, label='Data', ax=ax, **kwargs) ax, cBar = self.showData(data=self.inv.response, label='Response', ax=ax, **kwargs) if not kwargs.pop('hideFittingAnnotation', False): fittext = r"rrms: {0}, $\chi^2$: {1}".format( pf(self.fw.inv.relrms()), pf(self.fw.inv.chi2())) ax.text(0.99, 0.005, fittext, transform=ax.transAxes, horizontalalignment='right', verticalalignment='bottom', fontsize=8) if not kwargs.pop('hideLegend', False): ax.legend() return ax, cBar
[docs] def showResultAndFit(self, **kwargs): """Call showResults and showFit. Keyword Args ------------ saveFig: str[None] If not None save figure. axs: [mpl.Axes] Give 3 axes and its plotted into them instead of creating 3 new. """ saveFig = kwargs.pop('saveFig', None) if 'axs' in kwargs: axs = kwargs.pop('axs') if len(axs) != 3: pg.critical("You need to provide a list of 3 axes.") ax = axs[0] ax1 = axs[1] ax2 = axs[2] else: fig = pg.plt.figure(figsize=kwargs.pop('figsize', (11, 6))) ax = fig.add_subplot(1, 2, 1) ax1 = fig.add_subplot(2, 2, 2) ax2 = fig.add_subplot(2, 2, 4) fig = ax.figure if 'title' in kwargs: fig.suptitle(kwargs['title']) self.showResult(ax=ax, model=self.model, **kwargs) fig.modelAX = ax self.showFit(axs=[ax1, ax2], **kwargs) fig.dataAX = ax1 fig.respAX = ax2 fig.tight_layout() if saveFig is not None: fig.savefig(saveFig, bbox_inches="tight") return fig
[docs] @staticmethod def createArgParser(dataSuffix='dat'): """Create default argument parser. TODO move this to some kind of app class Create default argument parser for the following options: -Q, --quiet -R, --robustData: options.robustData -B, --blockyModel: options.blockyModel -l, --lambda: options.lam -i, --maxIter: options.maxIter --depth: options.depth """ import argparse parser = argparse.ArgumentParser( description="usage: %prog [options] *." + dataSuffix) parser.add_argument("-Q", "--quiet", dest="quiet", action="store_true", default=False, help="Be verbose.") # parser.add_argument("-R", "--robustData", dest="robustData", # action="store_true", default=False, # help="Robust data (L1 norm) minimization.") # parser.add_argument("-B", "--blockyModel", dest="blockyModel", # action="store_true", default=False, # help="Blocky model (L1 norm) regularization.") parser.add_argument('-l', "--lambda", dest="lam", type=float, default=100, help="Regularization strength.") parser.add_argument('-i', "--maxIter", dest="maxIter", type=int, default=20, help="Maximum iteration count.") # parser.add_argument("--depth", dest="depth", type=float, # default=None, # help="Depth of inversion domain. [None=auto].") parser.add_argument('dataFileName') return parser
[docs] class ParameterInversionManager(MethodManager): """Framework to invert unconstrained parameters."""
[docs] def __init__(self, funct=None, fop=None, **kwargs): """Inizialize instance.""" if fop is not None: if not isinstance(fop, pg.frameworks.ParameterModelling): pg.critical("We need a fop if type ", pg.frameworks.ParameterModelling) elif funct is not None: fop = pg.frameworks.ParameterModelling(funct) else: pg.critical("you should either give a valid fop or a function so " "I can create the fop for you") super(ParameterInversionManager, self).__init__(fop, **kwargs)
[docs] def createInversionFramework(self, **kwargs): """Create a Marquardt-type inversion framework instance.""" return pg.frameworks.MarquardtInversion(**kwargs)
[docs] def invert(self, data=None, err=None, **kwargs): """Run inversion. Parameters ---------- limits: {str: [min, max]} Set limits for parameter by parameter name. startModel: {str: startModel} Set the start value for parameter by parameter name. """ dataSpace = kwargs.pop(self.fop.dataSpaceName, None) if dataSpace is not None: if isinstance(dataSpace, list): dataSpace = np.array(dataSpace) self.fop.dataSpace = dataSpace limits = kwargs.pop('limits', {}) for k, v in limits.items(): self.fop.setRegionProperties(k, limits=v) startModel = kwargs.pop('startModel', {}) if isinstance(startModel, dict): for k, v in startModel.items(): self.fop.setRegionProperties(k, startModel=v) else: kwargs['startModel'] = startModel return super(ParameterInversionManager, self).invert(data=data, err=err, **kwargs)
[docs] class MethodManager1d(MethodManager): """Method Manager base class for managers on a 1d discretization."""
[docs] def __init__(self, fop=None, **kwargs): """Initialize with fop.""" super(MethodManager1d, self).__init__(fop, **kwargs)
[docs] def createInversionFramework(self, **kwargs): """Create inversion with block discretization.""" return pg.frameworks.Block1DInversion(**kwargs)
[docs] def invert(self, data=None, err=None, **kwargs): """Run inversion.""" return super(MethodManager1d, self).invert(data=data, err=err, **kwargs)
[docs] class MeshMethodManager(MethodManager): """Method manager for mesh-based modelling and inversion."""
[docs] def __init__(self, **kwargs): """Initialize by calling super methods. Attribute --------- mesh: :gimliapi:`GIMLI::Mesh` Copy of the main mesh to be distributed to inversion and the fop. You can overwrite it with invert(mesh=mesh). """ super().__init__(**kwargs) self.mesh = None
@property def model(self): """Inversion model.""" return self.paraModel(self.fw.model) @property def paraDomain(self): """Parameter (inversion) domain mesh.""" return self.fop.paraDomain
[docs] def paraModel(self, model=None): """Give the model parameter regarding the parameter mesh.""" if model is None: model = self.fw.model return self.fop.paraModel(model)
[docs] def createMesh(self, data=None, **kwargs): """Create default inversion mesh. Inversion mesh for traveltime inversion does not need boundary region. Args ---- data: DataContainer Data container to read sensors from. Keyword Args ------------ Forwarded to `:py:func:pygimli.meshtools.createParaMesh` """ data = data or self.data if not hasattr(data, 'sensors'): pg.critical('Please provide a data container for mesh generation') mesh = pg.meshtools.createParaMesh(data.sensors(), paraBoundary=0, boundary=0, **kwargs) self.setMesh(mesh) return mesh
[docs] def setMesh(self, mesh, **kwargs): """Set a mesh and distribute it to the forward operator.""" self.mesh = mesh # keep a copy of the original mesh self.applyMesh(mesh, **kwargs)
[docs] def applyMesh(self, mesh, ignoreRegionManager=False, **kwargs): """Pass the mesh along to the forward operator.""" if ignoreRegionManager is True: mesh = self.fop.createRefinedFwdMesh(mesh, **kwargs) self.fop.setMesh(mesh, ignoreRegionManager=ignoreRegionManager)
[docs] def invert(self, data=None, mesh=None, startModel=None, **kwargs): """Run the full inversion. Parameters ---------- data: pg.DataContainer mesh: :gimliapi:`GIMLI::Mesh` [None] startModel: float | iterable [None] If set to None fop.createDefaultStartModel(dataValues) is called. Keyword Arguments ----------------- zWeight : float [None] Set zWeight or use defaults from regionManager. correlationLengths : [float, float, float] Correlation lengths for geostatistical regularization dip : float rotation axis between first and last dimension (x and z) strike : float rotation axis between first and second dimension (x and y) limits: [float, float] lower and upper value bounds for logarithmic transformation Al other are forwarded to Inversion.run Returns ------- model : array Model mapped for match the paraDomain Cell markers. The calculated model vector (unmapped) is in self.fw.model. """ data = data or self.data if data is None: pg.critical('No data given for inversion') self.applyData(data) # no mesh given and there is no mesh known .. we create them if mesh is None and self.mesh is None: mesh = self.createMesh(data, **kwargs) # a mesh was given or created so we forward it to the fop if mesh is not None: self.setMesh(mesh) # could be done by createMesh if isinstance(data, pg.DataContainer) and mesh.dim() == 2: data.ensure2D() # remove unused keyword argument .. need better kwargfs self.fop._refineP2 = kwargs.pop('refineP2', False) dataVals = self._ensureData(self.fop.data) errorVals = self._ensureError(self.fop.data, dataVals) if self.fop.mesh() is None: pg.critical('Please provide a mesh') kwargs['startModel'] = startModel # take some global inversion option for all regions for kw in ["zWeight", "limits", "cType", "correlationLengths", "dip", "strike"]: if kw in kwargs: di = {kw: kwargs.pop(kw)} self.fop.setRegionProperties('*', **di) # if "blockyModel" in kwargs: # redundant, now in fw # self.fw.blockyModel = kwargs["blockyModel"] # if "stopAtChi1" in kwargs: # self.fw._stopAtChi1 = kwargs["stopAtChi1"] self.preRun(**kwargs) self.fw.run(dataVals, errorVals, **kwargs) self.postRun(**kwargs) return self.paraModel(self.fw.model)
[docs] def showFit(self, axs=None, **kwargs): """Show data and the inversion result model response.""" orientation = 'vertical' if axs is None: _, axs = pg.plt.subplots(nrows=1, ncols=2, sharex=True, sharey=True) orientation = 'horizontal' kwargs.setdefault("cMin", min(self.inv.dataVals)) kwargs.setdefault("cMax", max(self.inv.dataVals)) self.showData(data=self.inv.dataVals, orientation=orientation, ax=axs[0], **kwargs) axs[0].text(0.0, 1.03, "Data", transform=axs[0].transAxes, horizontalalignment='left', verticalalignment='center') resp = None data = None if 'model' in kwargs: resp = self.fop.response(kwargs['model']) data = self._ensureData(self.fop.data) else: resp = self.inv.response data = self.fw.dataVals self.showData(data=resp, orientation=orientation, ax=axs[1], **kwargs) axs[1].text(0.0, 1.03, "Response", transform=axs[1].transAxes, horizontalalignment='left', verticalalignment='center') fittext = r"rrms: {0}%, $\chi^2$: {1}".format( pg.pf(pg.utils.rrms(data, resp)*100), pg.pf(self.fw.chi2History[-1])) axs[1].text(1.0, 1.03, fittext, transform=axs[1].transAxes, horizontalalignment='right', verticalalignment='center') return axs
[docs] def coverage(self): """Coverage vector considering the logarithmic transformation.""" covTrans = pg.core.coverageDCtrans(self.fop.jacobian(), 1.0 / self.inv.response, 1.0 / self.inv.model) nCells = self.fop.paraDomain.cellCount() return np.log10(covTrans[:nCells] / self.fop.paraDomain.cellSizes())
[docs] def standardizedCoverage(self, threshold=0.01): """Standardized coverage vector (0|1) using thresholding.""" return 1.0*(abs(self.coverage()) > threshold)
[docs] class PetroInversionManager(MeshMethodManager): """Class for petrophysical inversion (s. Rücker et al. 2017)."""
[docs] def __init__(self, petro, mgr=None, **kwargs): """Initialize instance with manager and petrophysical relation.""" petrofop = kwargs.pop('petrofop', None) if petrofop is None: fop = kwargs.pop('fop', None) if fop is None and mgr is not None: # Check! why I can't use mgr.fop # fop = mgr.fop fop = mgr.createForwardOperator() self.checkData = mgr.checkData self.checkError = mgr.checkError if fop is not None: if not isinstance(fop, pg.frameworks.PetroModelling): petrofop = pg.frameworks.PetroModelling(fop, petro) if petrofop is None: print(mgr) print(fop) pg.critical('implement me') super().__init__(fop=petrofop, **kwargs)
[docs] class JointPetroInversionManager(MeshMethodManager): """Joint inversion targeting at the same parameter through petrophysics. This is just syntactic sugar for the combination of :py:mod:`pygimli.frameworks.PetroModelling` and :py:mod:`pygimli.frameworks.JointModelling`. """
[docs] def __init__(self, petros, mgrs): """Initialize with lists of managers and transformations.""" self.mgrs = mgrs self.fops = [pg.frameworks.PetroModelling(m.fop, p) for p, m in zip(petros, mgrs)] super().__init__(fop=pg.frameworks.JointModelling(self.fops)) # just hold a local copy self.dataTrans = pg.trans.TransCumulative()
[docs] def checkError(self, err, data=None): """Collect error values.""" if len(err) != len(self.mgrs): pg.critical("Please provide data for all managers") vals = pg.Vector(0) for i, mgr in enumerate(self.mgrs): # we get the data values again or we have to split data dataVals = mgr.checkData(self.fop._data[i]) vals = pg.cat(vals, mgr.checkError(err[i], dataVals)) return vals
[docs] def checkData(self, data): """Collect data values.""" if len(data) != len(self.mgrs): pg.critical("Please provide data for all managers") self.dataTrans.clear() vals = pg.Vector(0) for i, mgr in enumerate(self.mgrs): self.dataTrans.add(mgr.inv.dataTrans, data[i].size()) vals = pg.cat(vals, mgr.checkData(data[i])) self.inv.dataTrans = self.dataTrans return vals
[docs] def invert(self, data, **kwargs): """Run inversion.""" limits = kwargs.pop('limits', [0., 1.]) self.fop.modelTrans.setLowerBound(limits[0]) self.fop.modelTrans.setUpperBound(limits[1]) kwargs['startModel'] = kwargs.pop('startModel', (limits[1]+limits[0])/2.) return super().invert(data, **kwargs)