Repository: fatiando/verde (python)

Issue:
Metagridder class to chain operations
The scikit-learn `Pipeline` allows transformations of the Jacobian to be chained with a final estimator at the end. That's fine if you want to chain a scaling operation or PCA. When gridding, we usually want to remove a trend from the data itself, not the Jacobian. 

The `Chain` class will take a list of gridder classes and apply them in succession. Each new gridder will fit the residuals from the previous gridder. When predicting, the predictions of each component are summed:

```python
grd = Chain([('trend', Trend(degree=2)), ('grid', ScipyGridder())])
grd.fit(coordinates, data, weights) # fits ScipyGridder on the residuals of Trend
grd.predict(coordinates)  # sums the predictions
grd.grid() # Make a grid of the summed predictions
trend = grd.named_steps['trend'].grid() # Make a grid of the trend only
```

This is a convenient way of adding trends to all our gridders but keeping some level of control on the user side. It will also enable using the same code when implementing equivalent layers in `harmonica`. 

Pull request description:
Add Chain estimator
The `Chain` estimator takes a list of gridders. Each gridder will be fit
on the residuals of the previous one. For example, can be used to chain
trend estimation with gridding.

For a gridder to be compatible, it has to set a `residual_` attribute
in `fit` that contains `data - self.predict(coordinates)`. This is used
as input to the next gridder.

The interface is similar to the scikit-learn `Pipeline` estimator but
does not yet support everything (like accessing attributes from the
components).

Fixes #42

Patch:
diff --git a/examples/chain_trend.py b/examples/chain_trend.py
new file mode 100644
index 0000000..062300e
--- /dev/null
+++ b/examples/chain_trend.py
@@ -0,0 +1,84 @@
+"""
+Chaining operations
+===================
+
+The :class:`verde.Chain` class allows us to created gridders that perform
+multiple estimator operations on data. It will fit each estimator on the data
+residuals of the previous one. For example, say we want to calculate a
+polynomial trend for our data, fit a gridder on the residual values (not the
+trend), but then make a grid of the original data values. This is useful
+because many gridders can't handle trends in data.
+"""
+import numpy as np
+import matplotlib.pyplot as plt
+import cartopy.crs as ccrs
+# We need these two classes to set proper ticklabels for Cartopy maps
+from cartopy.mpl.ticker import LongitudeFormatter, LatitudeFormatter
+import pyproj
+import verde as vd
+
+# Load the Rio de Janeiro total field magnetic anomaly data
+data = vd.datasets.fetch_rio_magnetic_anomaly()
+region = vd.get_region(data.longitude, data.latitude)
+
+# Before gridding, we need to decimate the data using a blocked mean to avoid
+# aliasing
+spacing = 0.5/60
+lon, lat, total_field = vd.block_reduce(data.longitude, data.latitude,
+                                        data.total_field_anomaly_nt,
+                                        reduction=np.median, spacing=spacing)
+
+# Project the data using pyproj so that we can use it as input for the gridder.
+# We'll set the latitude of true scale to the mean latitude of the data.
+projection = pyproj.Proj(proj='merc', lat_ts=data.latitude.mean())
+coordinates = projection(lon, lat)
+
+# Create a chain that fits a 2nd degree trend to the anomaly data and then a
+# standard gridder to the residuals
+chain = vd.Chain([('trend', vd.Trend(degree=2)),
+                  ('gridder', vd.ScipyGridder())])
+print("Chained estimator:", chain)
+# Calling 'fit' will automatically run the data through the chain
+chain.fit(coordinates, total_field)
+
+# Each component of the chain can be accessed separately using the
+# 'named_steps' attribute
+grid_trend = chain.named_steps['trend'].grid()
+print("\nTrend grid:")
+print(grid_trend)
+
+grid_residual = chain.named_steps['gridder'].grid()
+print("\nResidual grid:")
+print(grid_residual)
+
+# Chain.grid will use both the trend and the gridder to predict values.
+# We'll use the 'projection' keyword to generate a geographic grid instead of
+# Cartesian
+grid = chain.grid(region=region, spacing=spacing, projection=projection,
+                  dims=['latitude', 'longitude'],
+                  data_names=['total_field_anomaly'])
+print("\nChained geographic grid:")
+print(grid)
+
+# We'll plot only the chained grid
+crs = ccrs.PlateCarree()
+
+plt.figure(figsize=(7, 5))
+ax = plt.axes(projection=ccrs.Mercator())
+ax.set_title("Chained trend and gridder")
+maxabs = np.max(np.abs([grid.total_field_anomaly.min(),
+                        grid.total_field_anomaly.max()]))
+pc = ax.pcolormesh(grid.longitude, grid.latitude, grid.total_field_anomaly,
+                   transform=crs, cmap='seismic', vmin=-maxabs, vmax=maxabs)
+cb = plt.colorbar(pc, pad=0.01)
+cb.set_label('total field anomaly [nT]')
+# Plot the locations of the decimated data
+ax.plot(lon, lat, '.k', markersize=0.5, transform=crs)
+# Set the proper ticks for a Cartopy map
+ax.set_xticks(np.arange(-42.5, -42, 0.1), crs=crs)
+ax.set_yticks(np.arange(-22.4, -22, 0.1), crs=crs)
+ax.xaxis.set_major_formatter(LongitudeFormatter())
+ax.yaxis.set_major_formatter(LatitudeFormatter())
+ax.set_extent(region, crs=crs)
+plt.tight_layout()
+plt.show()
diff --git a/examples/data/sample_rio_magnetic_anomaly.py b/examples/data/sample_rio_magnetic_anomaly.py
index 28a86d1..376e6ca 100644
--- a/examples/data/sample_rio_magnetic_anomaly.py
+++ b/examples/data/sample_rio_magnetic_anomaly.py
@@ -10,6 +10,8 @@ Geological Survey of Brazil (CPRM) through their `GEOSGB portal
 """
 import matplotlib.pyplot as plt
 import cartopy.crs as ccrs
+# We need these two classes to set proper ticklabels for Cartopy maps
+from cartopy.mpl.ticker import LongitudeFormatter, LatitudeFormatter
 import numpy as np
 import verde as vd
 
@@ -18,9 +20,10 @@ data = vd.datasets.fetch_rio_magnetic_anomaly()
 print(data.head())
 
 # Make a Mercator plot of the data using Cartopy
+crs = ccrs.PlateCarree()
 plt.figure(figsize=(7, 5))
 ax = plt.axes(projection=ccrs.Mercator())
-ax.set_title('Total-field Magnetic Anomaly of Rio de Janeiro', pad=25)
+ax.set_title('Total-field Magnetic Anomaly of Rio de Janeiro')
 # Since the data is diverging (going from negative to positive)
 # we need to center our colorbar on 0. To do this, we calculate
 # the maximum absolute value of the data to set vmin and vmax.
@@ -28,13 +31,16 @@ maxabs = np.max(np.abs([data.total_field_anomaly_nt.min(),
                         data.total_field_anomaly_nt.max()]))
 # Cartopy requires setting the projection of the original data through the
 # transform argument. Use PlateCarree for geographic data.
-plt.scatter(data.longitude, data.latitude, c=data.total_field_anomaly_nt,
-            s=1, cmap='seismic', vmin=-maxabs, vmax=maxabs,
-            transform=ccrs.PlateCarree())
-cb = plt.colorbar(pad=0.1)
+plt.scatter(data.longitude, data.latitude, c=data.total_field_anomaly_nt, s=1,
+            cmap='seismic', vmin=-maxabs, vmax=maxabs, transform=crs)
+cb = plt.colorbar(pad=0.01)
 cb.set_label('nT')
-ax.gridlines(draw_labels=True)
+# Set the proper ticks for a Cartopy map
+ax.set_xticks(np.arange(-42.5, -42, 0.1), crs=crs)
+ax.set_yticks(np.arange(-22.4, -22, 0.1), crs=crs)
+ax.xaxis.set_major_formatter(LongitudeFormatter())
+ax.yaxis.set_major_formatter(LatitudeFormatter())
 # Set the extent of the plot to the limits of the data
-ax.set_extent(vd.get_region(data.longitude, data.latitude))
+ax.set_extent(vd.get_region(data.longitude, data.latitude), crs=crs)
 plt.tight_layout()
 plt.show()
diff --git a/verde/__init__.py b/verde/__init__.py
index 40b9910..3ff401c 100644
--- a/verde/__init__.py
+++ b/verde/__init__.py
@@ -11,6 +11,7 @@ from .coordinates import scatter_points, grid_coordinates, inside, \
 from .grid_math import block_reduce
 from .scipy_bridge import ScipyGridder
 from .trend import Trend, trend_jacobian
+from .chain import Chain
 
 
 # Get the version number through versioneer
diff --git a/verde/base.py b/verde/base.py
index 2d4c70b..f3cf26a 100644
--- a/verde/base.py
+++ b/verde/base.py
@@ -16,6 +16,11 @@ class BaseGridder(BaseEstimator):
     data returned by it should be a 1d or 2d numpy array for scalar data or a
     tuple with 1d or 2d numpy arrays for each component of vector data.
 
+    Subclasses should define a ``residual_`` attribute after fitting that
+    contains the data residuals ``self.residual_ = data -
+    self.predict(coordinates)``. This is required for compatibility with
+    :class:`verde.Chain`.
+
     Doesn't define any new attributes.
 
     This is a subclass of :class:`sklearn.base.BaseEstimator` and must abide by
diff --git a/verde/chain.py b/verde/chain.py
new file mode 100644
index 0000000..4b4b5fa
--- /dev/null
+++ b/verde/chain.py
@@ -0,0 +1,120 @@
+"""
+Class for chaining gridders.
+"""
+from sklearn.utils.validation import check_is_fitted
+
+from .base import BaseGridder, check_data
+from .coordinates import get_region
+
+
+class Chain(BaseGridder):
+    """
+    Chain gridders to fit on each others residuals.
+
+    Given a set of gridders or trend estimators, :meth:`~verde.Chain.fit` will
+    fit each estimator on the data residuals of the previous one. When
+    predicting data, the predictions of each estimator will be added together.
+
+    This provides a convenient way to chaining operations like trend estimation
+    to a given gridder.
+
+    Parameters
+    ----------
+    steps : list
+        A list of ``('name', gridder)`` pairs where ``gridder`` is any verde
+        class that implements the gridder interface (including ``Chain``
+        itself).
+
+    Attributes
+    ----------
+    region_ : tuple
+        The boundaries (``[W, E, S, N]``) of the data used to fit the chain.
+        Used as the default region for the :meth:`~verde.Chain.grid` and
+        :meth:`~verde.Chain.scatter` methods.
+    residual_ : array or tuple of arrays
+        The data residual after all chained operations are applied to the data.
+    named_steps : dict
+        A dictionary version of *steps* where the ``'name'``  strings are keys
+        and the ``gridder`` objects are the values.
+
+    """
+
+    def __init__(self, steps):
+        self.steps = steps
+
+    @property
+    def named_steps(self):
+        """
+        A dictionary version of steps.
+        """
+        return dict(self.steps)
+
+    def fit(self, coordinates, data, weights=None):
+        """
+        Fit the chained estimators to the given data.
+
+        Each estimator in the chain is fitted to the residuals of the previous
+        estimator. The coordinates are preserved. Only the data values are
+        changed.
+
+        The data region is captured and used as default for the
+        :meth:`~verde.Chain.grid` and :meth:`~verde.Chain.scatter` methods.
+
+        All input arrays must have the same shape.
+
+        Parameters
+        ----------
+        coordinates : tuple of arrays
+            Arrays with the coordinates of each data point. Should be in the
+            following order: (easting, northing, vertical, ...).
+        data : array
+            The data values of each data point.
+        weights : None or array
+            If not None, then the weights assigned to each data point.
+            Typically, this should be 1 over the data uncertainty squared.
+
+        Returns
+        -------
+        self
+            Returns this estimator instance for chaining operations.
+
+        """
+        self.region_ = get_region(*coordinates[:2])
+        residuals = data
+        for _, step in self.steps:
+            step.fit(coordinates, residuals, weights)
+            residuals = step.residual_
+        self.residual_ = residuals
+        return self
+
+    def predict(self, coordinates):
+        """
+        Interpolate data on the given set of points.
+
+        Requires a fitted gridder (see :meth:`~verde.ScipyGridder.fit`).
+
+        Parameters
+        ----------
+        coordinates : tuple of arrays
+            Arrays with the coordinates of each data point. Should be in the
+            following order: (easting, northing, vertical, ...). Only easting
+            and northing will be used, all subsequent coordinates will be
+            ignored.
+
+        Returns
+        -------
+        data : array
+            The data values interpolated on the given points.
+
+        """
+        check_is_fitted(self, ['region_', 'residual_'])
+        result = None
+        for _, step in self.steps:
+            predicted = check_data(step.predict(coordinates))
+            if result is None:
+                result = [0 for i in range(len(predicted))]
+            for i, pred in enumerate(predicted):
+                result[i] += pred
+        if len(result) == 1:
+            result = result[0]
+        return result
diff --git a/verde/scipy_bridge.py b/verde/scipy_bridge.py
index c6ee745..7dd2586 100644
--- a/verde/scipy_bridge.py
+++ b/verde/scipy_bridge.py
@@ -1,13 +1,15 @@
 """
 A gridder that uses scipy.interpolate as the backend.
 """
+from warnings import warn
+
 import numpy as np
 from sklearn.utils.validation import check_is_fitted
 from scipy.interpolate import LinearNDInterpolator, NearestNDInterpolator, \
     CloughTocher2DInterpolator
 
 from .base import BaseGridder
-from . import get_region
+from .coordinates import get_region
 
 
 class ScipyGridder(BaseGridder):
@@ -45,7 +47,7 @@ class ScipyGridder(BaseGridder):
         self.method = method
         self.extra_args = extra_args
 
-    def fit(self, coordinates, data):
+    def fit(self, coordinates, data, weights=None):
         """
         Fit the interpolator to the given data.
 
@@ -65,6 +67,11 @@ class ScipyGridder(BaseGridder):
             ignored.
         data : array
             The data values that will be interpolated.
+        weights : None or array
+            If not None, then the weights assigned to each data point.
+            Typically, this should be 1 over the data uncertainty squared.
+            Ignored for this interpolator. Only present for compatibility with
+            other gridder.
 
         Returns
         -------
@@ -83,11 +90,15 @@ class ScipyGridder(BaseGridder):
             kwargs = {}
         else:
             kwargs = self.extra_args
+        if weights is not None:
+            warn("{} does not support weights and they will be ignored."
+                 .format(self.__class__.__name__))
         easting, northing = coordinates[:2]
         self.region_ = get_region(easting, northing)
         points = np.column_stack((np.ravel(easting), np.ravel(northing)))
         self.interpolator_ = classes[self.method](points, np.ravel(data),
                                                   **kwargs)
+        self.residual_ = data - self.predict(coordinates)
         return self
 
     def predict(self, coordinates):


Test patch:
diff --git a/verde/tests/test_chain.py b/verde/tests/test_chain.py
new file mode 100644
index 0000000..c1140f3
--- /dev/null
+++ b/verde/tests/test_chain.py
@@ -0,0 +1,35 @@
+"""
+Test the Chain class
+"""
+import numpy.testing as npt
+
+from ..datasets.synthetic import CheckerBoard
+from ..chain import Chain
+from ..scipy_bridge import ScipyGridder
+from ..trend import Trend
+
+
+def test_chain():
+    "Test chaining trend and gridder."
+    region = (1000, 5000, -8000, -7000)
+    synth = CheckerBoard(amplitude=100, w_east=1000, w_north=100)
+    synth.fit(region=region)
+    data = synth.scatter(size=5000, random_state=0)
+    east, north = coords = data.easting, data.northing
+    coefs = [1000, 0.2, -1.4]
+    trend = coefs[0] + coefs[1]*east + coefs[2]*north
+    all_data = trend + data.scalars
+
+    grd = Chain([('trend', Trend(degree=1)),
+                 ('gridder', ScipyGridder())])
+    grd.fit(coords, all_data)
+
+    npt.assert_allclose(grd.predict(coords), all_data)
+    npt.assert_allclose(grd.residual_, 0, atol=1e-5)
+    npt.assert_allclose(grd.named_steps['trend'].coef_, coefs, rtol=1e-2)
+    npt.assert_allclose(grd.named_steps['trend'].predict(coords), trend,
+                        rtol=1e-3)
+    # The residual is too small to test. Need a robust trend probably before
+    # this will work.
+    # npt.assert_allclose(grd.named_steps['gridder'].predict(coords),
+    #                     data.scalars, rtol=5e-2, atol=0.5)
diff --git a/verde/tests/test_scipy.py b/verde/tests/test_scipy.py
index df8fbe3..1299e1d 100644
--- a/verde/tests/test_scipy.py
+++ b/verde/tests/test_scipy.py
@@ -1,8 +1,11 @@
 """
 Test the scipy based interpolator.
 """
+import warnings
+
 import pytest
 import pandas as pd
+import numpy as np
 import numpy.testing as npt
 
 from ..scipy_bridge import ScipyGridder
@@ -21,6 +24,7 @@ def test_scipy_gridder_same_points():
         grd.fit((data.easting, data.northing), data.scalars)
         predicted = grd.predict((data.easting, data.northing))
         npt.assert_allclose(predicted, data.scalars)
+        npt.assert_allclose(grd.residual_, 0, atol=1e-5)
 
 
 def test_scipy_gridder():
@@ -70,3 +74,16 @@ def test_scipy_gridder_fails():
     grd = ScipyGridder(method='some invalid method name')
     with pytest.raises(ValueError):
         grd.fit((data.easting, data.northing), data.scalars)
+
+
+def test_scipy_gridder_warns():
+    "Check that a warning is issued when using weights."
+    data = CheckerBoard().fit().scatter(random_state=100)
+    weights = np.ones_like(data.scalars)
+    grd = ScipyGridder()
+    msg = "ScipyGridder does not support weights and they will be ignored."
+    with warnings.catch_warnings(record=True) as warn:
+        grd.fit((data.easting, data.northing), data.scalars, weights=weights)
+        assert len(warn) == 1
+        assert issubclass(warn[-1].category, UserWarning)
+        assert str(warn[-1].message) == msg