The fatiando package has been deprecated. Please check out the new tools in the Fatiando a Terra website: www.fatiando.org

Source code for fatiando.inversion.base

"""
The base classes for inverse problem solving.

See :mod:`fatiando.inversion` for examples, regularization, and more.

This module defines base classes that are used by the rest of the
``inversion`` package:

* :class:`~fatiando.inversion.base.MultiObjective`: A "container" class that
  emulates a sum of different  objective (goal) functions (like
  :class:`~fatiando.inversion.misfit.Misfit` or some form of
  :mod:`~fatiando.inversion.regularization`). When two of those classes are
  added they generate a ``MultiObjective`` object.
* :class:`~fatiando.inversion.base.OperatorMixin`: A mix-in class that defines
  the operators ``+`` and ``*`` (by a scalar). Used to give these properties to
  ``Misfit`` and the regularizing functions. Adding results in a
  ``MultiObjective``. Multiplying sets the ``regul_param`` of the class (like a
  scalar weight factor).
* :class:`~fatiando.inversion.base.OptimizerMixin`: A mix-in class that defines
  the ``fit`` and ``config`` methods for optimizing a ``Misfit`` or
  ``MultiObjective`` and fitting the model to the data.
* :class:`~fatiando.inversion.base.CachedMethod`: A class that wraps a method
  and caches the returned value. When the same argument (an array) is passed
  twice in a row, the class returns the cached value instead of recomputing.
* :class:`~fatiando.inversion.base.CachedMethodPermanent`: Like
  ``CachedMethod`` but always returns the cached value, regardless of the
  input. Effectively calculates only the first time the method is called.
  Useful for caching the Jacobian matrix in a linear problem.

----

"""
from __future__ import division
from future.utils import with_metaclass
from future.builtins import super, object, range, isinstance, zip, map
import hashlib
import copy
from abc import ABCMeta, abstractmethod
import numpy as np

from . import optimization


[docs]class OperatorMixin(object): """ Implements the operators + and * for the goal functions classes. This class is not meant to be used on its own. Use it as a parent to give the child class the + and * operators. Used in :class:`~fatiando.inversion.base.Misfit` and the regularization classes in :mod:`fatiando.inversion.regularization`. .. note:: Performing ``A + B`` produces a :class:`~fatiando.inversion.base.MultiObjetive` with copies of ``A`` and ``B``. .. note:: Performing ``scalar*A`` produces a copy of ``A`` with ``scalar`` set as the ``regul_param`` attribute. """ @property def regul_param(self): """ The regularization parameter (scale factor) for the objetive function. Defaults to 1. """ return getattr(self, '_regularizing_parameter', 1) @regul_param.setter def regul_param(self, value): """ Set the value of the regularizing parameter. """ self._regularizing_parameter = value for name in ['hessian', 'gradient', 'value']: if hasattr(self, name): method = getattr(self, name) iscached = (isinstance(method, CachedMethodPermanent) or isinstance(method, CachedMethod)) if iscached: method.hard_reset()
[docs] def copy(self, deep=False): """ Make a copy of me. """ if deep: obj = copy.deepcopy(self) else: obj = copy.copy(self) return obj
def __add__(self, other): """ Add two objective functions to make a MultiObjective. """ assert self.nparams == other.nparams, \ "Can't add goals with different number of parameters:" \ + ' {}, {}'.format(self.nparams, other.nparams) # Make a shallow copy of self to return. If returned self, doing # 'a = b + c' a and b would reference the same object. res = MultiObjective(self.copy(), other.copy()) return res def __mul__(self, other): """ Multiply the objective function by a scallar to set the `regul_param` attribute. """ if not isinstance(other, int) and not isinstance(other, float): raise TypeError('Can only multiply a Objective by a float or int') # Make a shallow copy of self to return. If returned self, doing # 'a = 10*b' a and b would reference the same object. obj = self.copy() obj.regul_param = obj.regul_param*other return obj def __rmul__(self, other): return self.__mul__(other)
[docs]class OptimizerMixin(with_metaclass(ABCMeta)): """ Defines ``fit`` and ``config`` methods plus all the optimization methods. This class is not meant to be used on its own. Use it as a parent to give the child class the methods it implements. Used in :class:`~fatiando.inversion.base.Misfit` and :class:`fatiando.inversion.base.MultiObjetive`. The :meth:`~fatiando.inversion.base.OptimizerMixin.config` method is used to configure the optimization method that will be used. The :meth:`~fatiando.inversion.base.OptimizerMixin.fit` method runs the optimization method configured and stores the computed parameter vector in the ``p_`` attribute. Some stats about the optimization process are stored in the ``stats_`` attribute as a dictionary. The minimum requirement for a class to inherit from ``OptimizerMixin`` is that it must define at least a :meth:`~fatiando.inversion.base.OptimizerMixin.value` method. """
[docs] def config(self, method, **kwargs): """ Configure the optimization method and its parameters. This sets the method used by :meth:`~fatiando.inversion.base.Objective.fit` and the keyword arguments that are passed to it. Parameters: * method : string The optimization method. One of: ``'linear'``, ``'newton'``, ``'levmarq'``, ``'steepest'``, ``'acor'`` Other keyword arguments that can be passed are the ones allowed by each method. Some methods have required arguments: * *newton*, *levmarq* and *steepest* require the ``initial`` argument (an initial estimate for the gradient descent) * *acor* requires the ``bounds`` argument (min/max values for the search space) See the corresponding docstrings for more information: * :meth:`~fatiando.inversion.optimization.linear` * :meth:`~fatiando.inversion.optimization.newton` * :meth:`~fatiando.inversion.optimization.levmarq` * :meth:`~fatiando.inversion.optimization.steepest` * :meth:`~fatiando.inversion.optimization.acor` """ kwargs = copy.deepcopy(kwargs) assert method in ['linear', 'newton', 'levmarq', 'steepest', 'acor'], \ "Invalid optimization method '{}'".format(method) if method in ['newton', 'levmarq', 'steepest']: assert 'initial' in kwargs, \ "Missing required *initial* argument for '{}'".format(method) if method == 'acor': assert 'bounds' in kwargs, \ "Missing required *bounds* argument for '{}'".format(method) if method == 'acor' and 'nparams' not in kwargs: kwargs['nparams'] = self.nparams self.fit_method = method self.fit_args = kwargs return self
[docs] def fit(self): """ Solve for the parameter vector that minimizes this objective function. Uses the optimization method and parameters defined using the :meth:`~fatiando.inversion.base.OptimizerMixin.config` method. The estimated parameter vector can be accessed through the ``p_`` attribute. A (possibly) formatted version (converted to a more manageable type) of the estimate can be accessed through the property ``estimate_``. """ not_configured = (getattr(self, 'fit_method', None) is None or getattr(self, 'fit_args', None) is None) if not_configured: if self.islinear: self.config('linear') else: self.config('levmarq', initial=np.ones(self.nparams)) optimizer = getattr(optimization, self.fit_method) # Make the generators from the optimization function if self.fit_method == 'linear': solver = optimizer(self.hessian(None), self.gradient(None), **self.fit_args) elif self.fit_method in ['newton', 'levmarq']: solver = optimizer(self.hessian, self.gradient, self.value, **self.fit_args) elif self.fit_method == 'steepest': solver = optimizer(self.gradient, self.value, **self.fit_args) elif self.fit_method == 'acor': solver = optimizer(self.value, **self.fit_args) # Run the optimizer to the end for i, p, stats in solver: continue self.p_ = p self.stats_ = stats return self
[docs] def fmt_estimate(self, p): """ Called when accessing the property ``estimate_``. Use this to convert the parameter vector (p) to a more useful form, like a geometric object, etc. Parameters: * p : 1d-array The parameter vector. Returns: * formatted Pretty much anything you want. """ return p
@property def estimate_(self): """ A nicely formatted version of the estimate. If the class implements a `fmt_estimate` method, this will its results. This can be used to convert the parameter vector to a more useful form, like a :mod:`fatiando.mesher` object. """ assert self.p_ is not None, "No estimate found. Run 'fit' first." return self.fmt_estimate(self.p_)
[docs]class MultiObjective(OptimizerMixin, OperatorMixin): """ An objective (goal) function with more than one component. This class is a linear combination of other goal functions (like :class:`~fatiando.inversion.misfit.Misfit` and regularization classes). It is automatically created by adding two goal functions that have the :class:`~fatiando.inversion.base.OperatorMixin` as a base class. Alternatively, you can create a ``MultiObjetive`` by passing the other goals function instances as arguments to the constructor. The ``MultiObjetive`` behaves like any other goal function object. It has ``fit`` and ``config`` methods and can be added and multiplied by a scalar with the same effects. Indexing a ``MultiObjetive`` will iterate over the component goal functions. Examples: To show how this class is generated and works, let's create a simple class that subclasses ``OperatorMixin``. >>> class MyGoal(OperatorMixin): ... def __init__(self, name, nparams, islinear): ... self.name = name ... self.islinear = islinear ... self.nparams = nparams ... def value(self, p): ... return 1 ... def gradient(self, p): ... return 2 ... def hessian(self, p): ... return 3 >>> a = MyGoal('A', 10, True) >>> b = MyGoal('B', 10, True) >>> c = a + b >>> type(c) <class 'fatiando.inversion.base.MultiObjective'> >>> c.size 2 >>> c.nparams 10 >>> c.islinear True >>> c[0].name 'A' >>> c[1].name 'B' Asking for the value, gradient, and Hessian of the ``MultiObjective`` will give me the sum of both components. >>> c.value(None) 2 >>> c.gradient(None) 4 >>> c.hessian(None) 6 Multiplying the ``MultiObjective`` by a scalar will set the regularization parameter for the sum. >>> d = 10*c >>> d.value(None) 20 >>> d.gradient(None) 40 >>> d.hessian(None) 60 All components must have the same number of parameters. For the moment, ``MultiObjetive`` doesn't handle multiple parameter vector (one for each objective function). >>> e = MyGoal("E", 20, True) >>> a + e Traceback (most recent call last): ... AssertionError: Can't add goals with different number of parameters: 10, 20 The ``MultiObjective`` will automatically detect if the problem remains linear or not. For example, adding a non-linear problem to a linear one makes the sum non-linear. >>> (a + b).islinear True >>> f = MyGoal('F', 10, False) >>> (a + f).islinear False >>> (f + f).islinear False """ def __init__(self, *args): self._components = self._unpack_components(args) self.size = len(self._components) self.p_ = None nparams = [obj.nparams for obj in self._components] assert all(nparams[0] == n for n in nparams[1:]), \ "Can't add goals with different number of parameters:" \ + ' ' + ', '.join(str(n) for n in nparams) self.nparams = nparams[0] if all(obj.islinear for obj in self._components): self.islinear = True else: self.islinear = False self._i = 0 # Tracker for indexing
[docs] def fit(self): super().fit() for obj in self: obj.p_ = self.p_ return self
fit.__doc__ = OptimizerMixin.fit.__doc__ # Pass along the configuration in case the classes need to change something # depending on the optimization method.
[docs] def config(self, *args, **kwargs): super().config(*args, **kwargs) for obj in self: if hasattr(obj, 'config'): obj.config(*args, **kwargs) return self
config.__doc__ = OptimizerMixin.config.__doc__ def _unpack_components(self, args): """ Find all the MultiObjective elements in components and unpack them into a single list. This is needed so that ``D = A + B + C`` can be indexed as ``D[0] == A, D[1] == B, D[2] == C``. Otherwise, ``D[1]`` would be a ``MultiObjetive == B + C``. """ components = [] for comp in args: if isinstance(comp, MultiObjective): components.extend([c*comp.regul_param for c in comp]) else: components.append(comp) return components def __len__(self): return self.size def __getitem__(self, i): return self._components[i] def __iter__(self): self._i = 0 return self def __next__(self): """ Used for iterating over the MultiObjetive. """ if self._i >= self.size: raise StopIteration comp = self.__getitem__(self._i) self._i += 1 return comp
[docs] def fmt_estimate(self, p): """ Format the current estimated parameter vector into a more useful form. Will call the ``fmt_estimate`` method of the first component goal function (the first term in the addition that created this object). """ return self._components[0].fmt_estimate(p)
[docs] def value(self, p): """ Return the value of the multi-objective function. This will be the sum of all goal functions that make up this multi-objective. Parameters: * p : 1d-array The parameter vector. Returns: * result : scalar (float, int, etc) The sum of the values of the components. """ return self.regul_param*sum(obj.value(p) for obj in self)
[docs] def gradient(self, p): """ Return the gradient of the multi-objective function. This will be the sum of all goal functions that make up this multi-objective. Parameters: * p : 1d-array The parameter vector. Returns: * result : 1d-array The sum of the gradients of the components. """ return self.regul_param*sum(obj.gradient(p) for obj in self)
[docs] def hessian(self, p): """ Return the hessian of the multi-objective function. This will be the sum of all goal functions that make up this multi-objective. Parameters: * p : 1d-array The parameter vector. Returns: * result : 2d-array The sum of the hessians of the components. """ return self.regul_param*sum(obj.hessian(p) for obj in self)
[docs]class CachedMethod(object): """ Wrap a method to cache it's output based on the hash of the input array. Store the output of calling the method on a numpy array. If the method is called in succession with the same input array, the cached result will be returned. If the method is called on a different array, the old result will be discarded and the new one stored. Uses SHA1 hashes of the input array to tell if it is the same array. .. note:: We need the object instance and method name instead of the bound method (like ``obj.method``) because we can't pickle bound methods. We need to be able to pickle so that the solvers can be passed between processes in parallelization. Parameters: * instance : object The instance of the object that has the method you want to cache. * meth : string The name of the method you want to cache. Examples: >>> import numpy as np >>> class MyClass(object): ... def __init__(self, cached=False): ... if cached: ... self.my_method = CachedMethod(self, 'my_method') ... def my_method(self, p): ... return p**2 >>> obj = MyClass(cached=False) >>> a = obj.my_method(np.arange(0, 5)) >>> a array([ 0, 1, 4, 9, 16]) >>> b = obj.my_method(np.arange(0, 5)) >>> a is b False >>> cached = MyClass(cached=True) >>> a = cached.my_method(np.arange(0, 5)) >>> a array([ 0, 1, 4, 9, 16]) >>> b = cached.my_method(np.arange(0, 5)) >>> a is b True >>> cached.my_method.hard_reset() >>> b = cached.my_method(np.arange(0, 5)) >>> a is b False >>> c = cached.my_method(np.arange(0, 5)) >>> b is c True >>> cached.my_method(np.arange(0, 6)) array([ 0, 1, 4, 9, 16, 25]) """ def __init__(self, instance, meth): self.array_hash = None self.cache = None self.instance = instance self.meth = meth method = getattr(self.instance.__class__, self.meth) setattr(self, '__doc__', getattr(method, '__doc__'))
[docs] def hard_reset(self): """ Delete the cached values. """ self.cache = None self.array_hash = None
def __call__(self, p=None): if p is None: p = getattr(self.instance, 'p_') p_hash = hashlib.sha1(p).hexdigest() if self.cache is None or self.array_hash != p_hash: # Update the cache self.array_hash = p_hash # Get the method from the class because the instance will overwrite # it with the CachedMethod instance. method = getattr(self.instance.__class__, self.meth) self.cache = method(self.instance, p) return self.cache
[docs]class CachedMethodPermanent(object): """ Wrap a method to cache it's output and return it whenever the method is called.. This is different from :class:`~fatiando.inversion.base.CachedMethod` because it will only run the method once. All other times, the result returned will be this first one. This class should be used with methods that should return always the same output (like the Jacobian matrix of a linear problem). .. note:: We need the object instance and method name instead of the bound method (like ``obj.method``) because we can't pickle bound methods. We need to be able to pickle so that the solvers can be passed between processes in parallelization. Parameters: * instance : object The instance of the object that has the method you want to cache. * meth : string The name of the method you want to cache. Examples: >>> import numpy as np >>> class MyClass(object): ... def __init__(self, cached=False): ... if cached: ... self.my_method = CachedMethodPermanent(self, 'my_method') ... def my_method(self, p): ... return p**2 >>> obj = MyClass(cached=False) >>> a = obj.my_method(np.arange(0, 5)) >>> a array([ 0, 1, 4, 9, 16]) >>> b = obj.my_method(np.arange(0, 5)) >>> a is b False >>> cached = MyClass(cached=True) >>> a = cached.my_method(np.arange(0, 5)) >>> a array([ 0, 1, 4, 9, 16]) >>> b = cached.my_method(np.arange(0, 5)) >>> a is b True >>> c = cached.my_method(np.arange(10, 15)) >>> c array([ 0, 1, 4, 9, 16]) >>> a is c True """ def __init__(self, instance, meth): self.cache = None self.instance = instance self.meth = meth
[docs] def hard_reset(self): """ Delete the cached values. """ self.cache = None
def __call__(self, p=None): if self.cache is None: method = getattr(self.instance.__class__, self.meth) self.cache = method(self.instance, p) return self.cache