Skip to content

Commit 578bbd7

Browse files
authored
Don't use the Jacobian for prediction (#149)
All gridders create a Jacobian matrix to make predictions (using a dot product with the estimated parameters). This is very inefficient with regards to memory. Generating largeish grids requires a huge amount of RAM. Replace these with a for loop summation. Accelerate them with numba to achieve the same performance. Refactor the Green's functions calculations into separate functions that can be JITed. The pure Python version is slower than the dot product but at least it won't blow up memory.
1 parent 4247d44 commit 578bbd7

File tree

5 files changed

+225
-91
lines changed

5 files changed

+225
-91
lines changed

verde/spline.py

Lines changed: 75 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
from .utils import dummy_jit as jit
1919

2020

21+
# Default arguments for numba.jit
22+
JIT_ARGS = dict(nopython=True, target="cpu", fastmath=True, parallel=True)
23+
24+
2125
class Spline(BaseGridder):
2226
r"""
2327
Biharmonic spline interpolation using Green's functions.
@@ -71,14 +75,14 @@ class Spline(BaseGridder):
7175
then will be set to the data coordinates the first time
7276
:meth:`~verde.Spline.fit` is called.
7377
engine : str
74-
Computation engine for the Jacobian matrix. Can be ``'auto'``, ``'numba'``, or
75-
``'numpy'``. If ``'auto'``, will use numba if it is installed or numpy
76-
otherwise. The numba version is multi-threaded and considerably faster, which
78+
Computation engine for the Jacobian matrix and prediction. Can be ``'auto'``,
79+
``'numba'``, or ``'numpy'``. If ``'auto'``, will use numba if it is installed or
80+
numpy otherwise. The numba version is multi-threaded and usually faster, which
7781
makes fitting and predicting faster.
7882

7983
Attributes
8084
----------
81-
forces_ : array
85+
force_ : array
8286
The estimated forces that fit the observed data.
8387
region_ : tuple
8488
The boundaries (``[W, E, S, N]``) of the data used to fit the
@@ -152,9 +156,19 @@ def predict(self, coordinates):
152156

153157
"""
154158
check_is_fitted(self, ["force_"])
155-
jac = self.jacobian(coordinates[:2], self.force_coords)
156159
shape = np.broadcast(*coordinates[:2]).shape
157-
return jac.dot(self.force_).reshape(shape)
160+
force_east, force_north = n_1d_arrays(self.force_coords, n=2)
161+
east, north = n_1d_arrays(coordinates, n=2)
162+
data = np.empty(east.size, dtype=east.dtype)
163+
if parse_engine(self.engine) == "numba":
164+
data = predict_numba(
165+
east, north, force_east, force_north, self.mindist, self.force_, data
166+
)
167+
else:
168+
data = predict_numpy(
169+
east, north, force_east, force_north, self.mindist, self.force_, data
170+
)
171+
return data.reshape(shape)
158172

159173
def jacobian(self, coordinates, force_coords, dtype="float64"):
160174
"""
@@ -183,12 +197,14 @@ def jacobian(self, coordinates, force_coords, dtype="float64"):
183197
"""
184198
force_east, force_north = n_1d_arrays(force_coords, n=2)
185199
east, north = n_1d_arrays(coordinates, n=2)
200+
jac = np.empty((east.size, force_east.size), dtype=dtype)
186201
if parse_engine(self.engine) == "numba":
187-
jac = np.empty((east.size, force_east.size), dtype=dtype)
188-
jacobian_numba(east, north, force_east, force_north, self.mindist, jac)
202+
jac = jacobian_numba(
203+
east, north, force_east, force_north, self.mindist, jac
204+
)
189205
else:
190206
jac = jacobian_numpy(
191-
east, north, force_east, force_north, self.mindist, dtype
207+
east, north, force_east, force_north, self.mindist, jac
192208
)
193209
return jac
194210

@@ -214,34 +230,59 @@ def warn_weighted_exact_solution(spline, weights):
214230
)
215231

216232

217-
@jit(nopython=True, target="cpu", fastmath=True, parallel=True)
218-
def jacobian_numba(east, north, force_east, force_north, mindist, jac):
219-
"""
220-
Calculate the Jacobian matrix using numba to speed things up.
221-
"""
233+
def greens_func(east, north, mindist):
234+
"Calculate the Green's function for the Bi-Harmonic Spline"
235+
distance = np.sqrt(east ** 2 + north ** 2)
236+
# The mindist factor helps avoid singular matrices when the force and
237+
# computation point are too close
238+
distance += mindist
239+
return (distance ** 2) * (np.log(distance) - 1)
240+
241+
242+
def predict_numpy(east, north, force_east, force_north, mindist, forces, result):
243+
"Calculate the predicted data using numpy."
244+
result[:] = 0
245+
for j in range(forces.size):
246+
green = greens_func(east - force_east[j], north - force_north[j], mindist)
247+
result += green * forces[j]
248+
return result
249+
250+
251+
def jacobian_numpy(east, north, force_east, force_north, mindist, jac):
252+
"Calculate the Jacobian using numpy broadcasting."
253+
# Reshaping the data to a column vector will automatically build a distance matrix
254+
# between each data point and force.
255+
jac[:] = greens_func(
256+
east.reshape((east.size, 1)) - force_east,
257+
north.reshape((north.size, 1)) - force_north,
258+
mindist,
259+
)
260+
return jac
261+
262+
263+
@jit(**JIT_ARGS)
264+
def predict_numba(east, north, force_east, force_north, mindist, forces, result):
265+
"Calculate the predicted data using numba to speed things up."
222266
for i in numba.prange(east.size): # pylint: disable=not-an-iterable
267+
result[i] = 0
268+
for j in range(forces.size):
269+
green = GREENS_FUNC_JIT(
270+
east[i] - force_east[j], north[i] - force_north[j], mindist
271+
)
272+
result[i] += green * forces[j]
273+
return result
274+
275+
276+
@jit(**JIT_ARGS)
277+
def jacobian_numba(east, north, force_east, force_north, mindist, jac):
278+
"Calculate the Jacobian matrix using numba to speed things up."
279+
for i in range(east.size):
223280
for j in range(force_east.size):
224-
distance = np.sqrt(
225-
(east[i] - force_east[j]) ** 2 + (north[i] - force_north[j]) ** 2
281+
jac[i, j] = GREENS_FUNC_JIT(
282+
east[i] - force_east[j], north[i] - force_north[j], mindist
226283
)
227-
distance += mindist
228-
jac[i, j] = (distance ** 2) * (np.log(distance) - 1)
229284
return jac
230285

231286

232-
def jacobian_numpy(east, north, force_east, force_north, mindist, dtype):
233-
"""
234-
Calculate the Jacobian using numpy broadcasting. Is slightly slower than the numba
235-
version.
236-
"""
237-
# Reshaping the data to a column vector will automatically build a
238-
# distance matrix between each data point and force.
239-
distance = np.hypot(
240-
east.reshape((east.size, 1)) - force_east,
241-
north.reshape((north.size, 1)) - force_north,
242-
dtype=dtype,
243-
)
244-
# The mindist factor helps avoid singular matrices when the force and
245-
# computation point are too close
246-
distance += mindist
247-
return (distance ** 2) * (np.log(distance) - 1)
287+
# Jit compile the Green's functions for use in the numba functions
288+
GREENS_FUNC_JIT = jit(**JIT_ARGS)(greens_func)

verde/tests/test_spline.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,14 @@ def test_spline_jacobian_implementations():
116116
jac_numpy = Spline(engine="numpy").jacobian(coords, coords)
117117
jac_numba = Spline(engine="numba").jacobian(coords, coords)
118118
npt.assert_allclose(jac_numpy, jac_numba)
119+
120+
121+
@requires_numba
122+
def test_spline_predict_implementations():
123+
"Compare the numba and numpy implementations."
124+
size = 500
125+
data = CheckerBoard().scatter(size=size, random_state=1)
126+
coords = (data.easting, data.northing)
127+
pred_numpy = Spline(engine="numpy").fit(coords, data.scalars).predict(coords)
128+
pred_numba = Spline(engine="numba").fit(coords, data.scalars).predict(coords)
129+
npt.assert_allclose(pred_numpy, pred_numba)

verde/tests/test_vector.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
def data2d():
1919
"Make 2 component vector data"
2020
synth = CheckerBoard()
21-
coordinates = grid_coordinates(synth.region, shape=(30, 25))
21+
coordinates = grid_coordinates(synth.region, shape=(15, 20))
2222
data = tuple(synth.predict(coordinates).ravel() for i in range(2))
2323
return tuple(i.ravel() for i in coordinates), data
2424

@@ -38,7 +38,7 @@ def test_vector2d(data2d):
3838
def test_vector2d_weights(data2d):
3939
"Use unit weights and a regular grid solution"
4040
coords, data = data2d
41-
outlier = 100
41+
outlier = 10
4242
outlier_value = 100000
4343
data_outlier = tuple(i.copy() for i in data)
4444
data_outlier[0][outlier] += outlier_value
@@ -70,6 +70,15 @@ def test_vector2d_jacobian_implementations(data2d):
7070
npt.assert_allclose(jac_numpy, jac_numba)
7171

7272

73+
@requires_numba
74+
def test_vector2d_predict_implementations(data2d):
75+
"Compare the numba and numpy implementations."
76+
coords, data = data2d
77+
pred_numpy = VectorSpline2D(engine="numpy").fit(coords, data).predict(coords)
78+
pred_numba = VectorSpline2D(engine="numba").fit(coords, data).predict(coords)
79+
npt.assert_allclose(pred_numpy, pred_numba, atol=1e-4)
80+
81+
7382
########################################################################################
7483
# Test the Vector meta-gridder
7584

verde/trend.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from .base import BaseGridder, check_fit_input, least_squares
88
from .coordinates import get_region
9+
from .utils import n_1d_arrays
910

1011

1112
class Trend(BaseGridder):
@@ -91,8 +92,9 @@ def fit(self, coordinates, data, weights=None):
9192

9293
"""
9394
coordinates, data, weights = check_fit_input(coordinates, data, weights)
94-
self.region_ = get_region(coordinates[:2])
95-
jac = self.jacobian(coordinates[:2], dtype=data.dtype)
95+
easting, northing = n_1d_arrays(coordinates, 2)
96+
self.region_ = get_region((easting, northing))
97+
jac = self.jacobian((easting, northing), dtype=data.dtype)
9698
self.coef_ = least_squares(jac, data, weights, damping=None)
9799
return self
98100

@@ -117,9 +119,13 @@ def predict(self, coordinates):
117119

118120
"""
119121
check_is_fitted(self, ["coef_"])
120-
jac = self.jacobian(coordinates[:2])
122+
easting, northing = n_1d_arrays(coordinates, 2)
121123
shape = np.broadcast(*coordinates[:2]).shape
122-
return jac.dot(self.coef_).reshape(shape)
124+
data = np.zeros(easting.size, dtype=easting.dtype)
125+
combinations = polynomial_power_combinations(self.degree)
126+
for coef, (i, j) in zip(self.coef_, combinations):
127+
data += (easting ** i) * (northing ** j) * coef
128+
return data.reshape(shape)
123129

124130
def jacobian(self, coordinates, dtype="float64"):
125131
"""
@@ -162,15 +168,15 @@ def jacobian(self, coordinates, dtype="float64"):
162168
[ 1 4 -1 16 -4 1]]
163169

164170
"""
165-
easting, northing = coordinates[:2]
171+
easting, northing = n_1d_arrays(coordinates, 2)
166172
if easting.shape != northing.shape:
167173
raise ValueError("Coordinate arrays must have the same shape.")
168174
combinations = polynomial_power_combinations(self.degree)
169175
ndata = easting.size
170176
nparams = len(combinations)
171177
out = np.empty((ndata, nparams), dtype=dtype)
172178
for col, (i, j) in enumerate(combinations):
173-
out[:, col] = (easting.ravel() ** i) * (northing.ravel() ** j)
179+
out[:, col] = (easting ** i) * (northing ** j)
174180
return out
175181

176182

0 commit comments

Comments
 (0)