Skip to content

Commit a2992be

Browse files
authored
Restore use of NonlinearMagneticDipoleBz location estimates (#124)
This PR restores the use of source locations estimated by `NonlinearMagneticDipoleBz`, replacing the temporary use of Euler Deconvolution results. The inversion algorithm was corrected to fix the inaccurate location estimates previously produced. After the fix, `NonlinearMagneticDipoleBz` now provides accurate and consistent positions, better than those from Euler Deconvolution. This update affects the `iterative_nonlinear_inversion` function, which implements the full methodology presented by [Souza-Junior (2025)](https://eartharxiv.org/repository/view/8869/). In our implementation, the original Nelder–Mead–based inversion from the paper is replaced by a more robust Levenberg–Marquardt–based inversion **Changes made** - **Set a lower limit to `r²`** to avoid excessively large derivatives when source–data distances become very small. - **Construct the damping matrix proportional to the diagonal of the Hessian**, ensuring that regularization adapts to parameter sensitivity. - **Reduce the global scaling factor** to stabilize updates and prevent overshooting during optimization. Together, these changes make the inversion more robust against numerical instabilities and improve convergence consistency across different datasets. **Relevant issues/PRs:** #121 #120 #107
1 parent 5e09d55 commit a2992be

File tree

2 files changed

+221
-39
lines changed

2 files changed

+221
-39
lines changed

src/magali/_inversion.py

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@
1414
import numpy as np
1515
import verde as vd
1616

17-
import magali as mg
18-
1917
from ._synthetic import dipole_bz
2018
from ._units import (
2119
coordinates_micrometer_to_meter,
2220
meter_to_micrometer,
2321
tesla_to_nanotesla,
2422
)
23+
from ._utils import gradient
2524
from ._validation import check_fit_input
2625

2726

@@ -113,7 +112,7 @@ def jacobian(self, coordinates):
113112

114113
def _jacobian_linear(x, y, z, xc, yc, zc, result):
115114
"""
116-
Jit-compiled version of the Jacobian matrix calculation for the linear inversion.
115+
JIT-compiled Jacobian calculation for linear inversion.
117116
"""
118117
factor = choclo.constants.VACUUM_MAGNETIC_PERMEABILITY / (4 * np.pi)
119118
n_data = x.size
@@ -129,7 +128,7 @@ def _jacobian_linear(x, y, z, xc, yc, zc, result):
129128

130129
class NonlinearMagneticDipoleBz:
131130
"""
132-
Estimate the position and magnetic dipole moment vector from Bz measurements.
131+
Estimate dipole position and moment from Bz data.
133132
134133
Uses the Bz component of the magnetic field to estimate both the position
135134
and the moment of a magnetic dipole through a nonlinear inversion based on
@@ -140,17 +139,19 @@ class NonlinearMagneticDipoleBz:
140139
Parameters
141140
----------
142141
initial_location : tuple of float
143-
Initial guess for the coordinates (x, y, z) of the dipole location, in µm.
142+
Initial guess for the coordinates (x, y, z) of the dipole location, in
143+
µm.
144144
max_iter : int
145145
Maximum number of iterations for both the outer and inner loops of the
146146
nonlinear inversion.
147147
tol : float
148-
Convergence tolerance for the relative change in misfit between iterations.
148+
Convergence tolerance for the relative change in misfit between
149+
iterations.
149150
alpha_init : float
150151
Initial damping parameter for the Levenberg-Marquardt algorithm.
151152
alpha_scale : float
152-
Multiplicative factor used to increase or decrease the damping parameter
153-
during the optimization.
153+
Multiplicative factor used to increase or decrease the damping
154+
parameter during the optimization.
154155
155156
Attributes
156157
----------
@@ -170,7 +171,7 @@ def __init__(
170171
self,
171172
initial_location,
172173
max_iter=100,
173-
tol=1e-4,
174+
tol=1e-2,
174175
alpha_init=1,
175176
alpha_scale=10.0,
176177
):
@@ -196,8 +197,8 @@ def predict(self, coordinates):
196197
Returns
197198
-------
198199
predicted : array
199-
Array with the predicted Bz values (in nT) at the observation points.
200-
Has the same shape as the input coordinate arrays.
200+
Array with the predicted Bz values (in nT) at the observation
201+
points. Has the same shape as the input coordinate arrays.
201202
202203
Raises
203204
------
@@ -266,36 +267,41 @@ def fit(self, coordinates, data):
266267
267268
Performs nonlinear inversion using the Levenberg-Marquardt method to
268269
estimate both the dipole location and its magnetic moment. The method
269-
alternates between a nonlinear update of the dipole location (inner loop)
270-
and a linear least-squares estimate of the dipole moment (outer loop).
271-
The Jacobian matrix with respect to the location is computed numerically
272-
using JIT-accelerated code.
270+
alternates between a nonlinear update of the dipole location (inner
271+
loop) and a linear least-squares estimate of the dipole moment (outer
272+
loop). The Jacobian matrix with respect to the location is computed
273+
numerically using JIT-accelerated code.
273274
274275
The Jacobian matrix used in the nonlinear step contains partial
275276
derivatives of the Bz field with respect to the dipole location:
276-
:math:`\frac{\partial B_z}{\partial x_0}`, :math:`\frac{\partial B_z}{\partial y_0}`,
277-
and :math:`\frac{\partial B_z}{\partial z_0}`. These are computed assuming a fixed
278-
moment vector.
277+
:math:`\frac{\partial B_z}{\partial x_0}`,
278+
:math:`\frac{\partial B_z}{\partial y_0}`, and
279+
:math:`\frac{\partial B_z}{\partial z_0}`. These are computed assuming
280+
a fixed moment vector.
279281
280282
At each inner iteration:
281283
282-
- The forward field is computed using the trial location and fixed moment.
284+
- The forward field is computed using the trial location and fixed
285+
moment.
283286
- A trial update is accepted if it reduces the data misfit.
284287
- The damping parameter (alpha) is adapted based on success/failure.
285288
286289
At each outer iteration:
287290
288-
- A new linear estimate of the dipole moment is computed for the current location.
291+
- A new linear estimate of the dipole moment is computed for the
292+
current location.
289293
- Convergence is assessed based on relative reduction in residual norm.
290294
291295
Parameters
292296
----------
293297
coordinates : tuple of array-like
294298
Arrays with the x, y, z coordinates of the observation points.
295-
The arrays can have any shape as long as they all have the same shape.
299+
The arrays can have any shape as long as they all have the same
300+
shape.
296301
data : array-like
297-
Observed Bz component of the magnetic field (in nT) at the observation
298-
points. Must have the same shape as the coordinate arrays.
302+
Observed Bz component of the magnetic field (in nT) at the
303+
observation points. Must have the same shape as the coordinate
304+
arrays.
299305
300306
Returns
301307
-------
@@ -308,11 +314,12 @@ def fit(self, coordinates, data):
308314
Internally uses:
309315
310316
- :func:`jacobian_nonlinear_jit`: JIT-compiled function that fills the
311-
Jacobian matrix :math:`\frac{\partial B_z}{\partial (x_0, y_0, z_0)}` for a fixed moment.
312-
- :func:`dipole_bz`: forward model for the Bz field of a dipole at given
313-
coordinates.
314-
- :class:`MagneticMomentBz`: linear inversion for estimating moment given a
315-
fixed location.
317+
Jacobian matrix :math:`\frac{\partial B_z}{\partial (x_0, y_0, z_0)}`
318+
for a fixed moment.
319+
- :func:`dipole_bz`: forward model for the Bz field of a dipole at
320+
given coordinates.
321+
- :class:`MagneticMomentBz`: linear inversion for estimating moment
322+
given a fixed location.
316323
"""
317324
coordinates, data = check_fit_input(coordinates, data)
318325
coordinates_m = tuple(
@@ -324,18 +331,29 @@ def fit(self, coordinates, data):
324331
moment = linear_model.dipole_moment_
325332
residual = data - dipole_bz(coordinates, location, moment)
326333
misfit = [np.linalg.norm(residual)]
327-
alpha = self.alpha_init
328334
jacobian = np.empty((data.size, 3))
329-
identity = np.identity(3)
330335
for _ in range(self.max_iter):
331336
location_misfit = [misfit[-1]]
332337
for _ in range(self.max_iter):
333338
jacobian = self.jacobian(coordinates_m, location, moment, jacobian)
334339
hessian = jacobian.T @ jacobian
340+
# Make alpha proportional to the curvature scale
341+
scaling_factor = 1e-20
342+
alpha = scaling_factor * max(np.median(np.diag(hessian)), 1e-30)
335343
gradient = jacobian.T @ residual
336344
took_a_step = False
337345
for _ in range(50):
338-
delta = np.linalg.solve(hessian + alpha * identity, gradient)
346+
# build damping matrix proportional to diag(H)
347+
diagH = np.diag(np.diag(hessian))
348+
damping = alpha * diagH
349+
# small floor to avoid zero diagonal
350+
damping += 1e-20 * np.eye(3)
351+
delta = np.linalg.solve(hessian + damping, gradient)
352+
max_step_m = 1e-6 # 10 µm
353+
step_norm = np.linalg.norm(delta)
354+
if step_norm > max_step_m:
355+
delta = delta * (max_step_m / step_norm)
356+
339357
trial_location = location + meter_to_micrometer(delta)
340358
trial_predicted = dipole_bz(
341359
coordinates,
@@ -380,14 +398,16 @@ def fit(self, coordinates, data):
380398

381399
def _jacobian_nonlinear(x, y, z, xc, yc, zc, mx, my, mz, result):
382400
"""
383-
Jit-compiled version of the Jacobian matrix calculation for the nonlinear inversion.
401+
JIT-compiled Jacobian for nonlinear inversion.
384402
"""
385403
factor = choclo.constants.VACUUM_MAGNETIC_PERMEABILITY / (4 * np.pi)
386404
for i in numba.prange(x.size):
387405
dx = x[i] - xc
388406
dy = y[i] - yc
389407
dz = z[i] - zc
390408
r2 = dx**2 + dy**2 + dz**2
409+
# prevent huge derivatives if (xc,yc,zc) gets very near an observation
410+
r2 = max(r2, 1e-18)
391411
r5 = r2 ** (5 / 2)
392412
r7 = r2 ** (7 / 2)
393413
# ∂bz / ∂xc
@@ -477,7 +497,7 @@ def iterative_nonlinear_inversion(
477497
for box in bounding_boxes:
478498
anomaly = data_updated.sel(x=slice(*box[:2]), y=slice(*box[2:]))
479499

480-
dx, dy, dz, tga = mg.gradient(anomaly)
500+
dx, dy, dz, tga = gradient(anomaly)
481501
anomaly["dx"], anomaly["dy"], anomaly["dz"], anomaly["tga"] = dx, dy, dz, tga
482502

483503
table = vd.grid_to_table(anomaly)
@@ -488,20 +508,20 @@ def iterative_nonlinear_inversion(
488508
bz_corrected = table.bz.values - euler.base_level_
489509
coordinates = (table.x.values, table.y.values, table.z.values)
490510

491-
model_nl = mg.NonlinearMagneticDipoleBz(
511+
model_nl = NonlinearMagneticDipoleBz(
492512
initial_location=euler.location_, max_iter=1000
493513
)
494514
model_nl.fit(coordinates, bz_corrected)
495515

496-
locations_.append(euler.location_)
516+
locations_.append(model_nl.location_)
497517
dipole_moments_.append(model_nl.dipole_moment_)
498518
r2_values.append(model_nl.r2_)
499519

500-
modeled_bz = mg.dipole_bz(
520+
modeled_bz = dipole_bz(
501521
global_coordinates, model_nl.location_, model_nl.dipole_moment_
502522
)
503-
for x_val, y_val, bz_val in zip(table.x.values, table.y.values, modeled_bz):
504-
data_updated.loc[{"x": x_val, "y": y_val}] -= bz_val
523+
for x, y, bz in zip(table.x, table.y, modeled_bz):
524+
data_updated.loc[{"x": x, "y": y}] -= bz
505525

506526
data_updated = (
507527
hm.upward_continuation(data_updated, height_difference)
@@ -510,7 +530,7 @@ def iterative_nonlinear_inversion(
510530
.assign_coords(z=data_updated.z + height_difference)
511531
.rename("bz")
512532
)
513-
dx, dy, dz, tga = mg.gradient(data_updated)
533+
dx, dy, dz, tga = gradient(data_updated)
514534
data_updated["dx"] = dx
515535
data_updated["dy"] = dy
516536
data_updated["dz"] = dz

0 commit comments

Comments
 (0)