ertkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ertkit/__init__.py +24 -0
- ertkit/_linalg.py +32 -0
- ertkit/_show_versions.py +49 -0
- ertkit/_version.py +24 -0
- ertkit/_warnings.py +18 -0
- ertkit/arrays.py +71 -0
- ertkit/noise.py +134 -0
- ertkit/plot/__init__.py +19 -0
- ertkit/plot/_ves.py +234 -0
- ertkit/py.typed +0 -0
- ertkit/results.py +82 -0
- ertkit/ves/__init__.py +28 -0
- ertkit/ves/data.py +167 -0
- ertkit/ves/forward.py +232 -0
- ertkit/ves/invert.py +632 -0
- ertkit/ves/io.py +179 -0
- ertkit/ves/model.py +86 -0
- ertkit/ves/smooth.py +508 -0
- ertkit/ves/survey.py +119 -0
- ertkit-0.1.0.dist-info/METADATA +156 -0
- ertkit-0.1.0.dist-info/RECORD +23 -0
- ertkit-0.1.0.dist-info/WHEEL +4 -0
- ertkit-0.1.0.dist-info/licenses/LICENSE +21 -0
ertkit/ves/invert.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
r"""Inversion of VES data for layered-earth models.
|
|
2
|
+
|
|
3
|
+
The few-layer inversion estimates :math:`m = (\ln \rho_i, \ln h_i)` by
|
|
4
|
+
minimizing the misfit of the log data,
|
|
5
|
+
|
|
6
|
+
.. math::
|
|
7
|
+
|
|
8
|
+
\chi^2 = \sum_{i=1}^{N_d} \left( \frac{d_i - f_i(m)}{s_i} \right)^2,
|
|
9
|
+
\qquad d_i = \ln \rho_{a,i},
|
|
10
|
+
|
|
11
|
+
with a Levenberg-Marquardt-type trust-region method
|
|
12
|
+
(:func:`scipy.optimize.least_squares`) from several starting models. The
|
|
13
|
+
Jacobian is computed by complex-step differentiation, which is exact to
|
|
14
|
+
round-off.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import operator
|
|
20
|
+
import warnings
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from numpy.typing import NDArray
|
|
25
|
+
from scipy import optimize, stats
|
|
26
|
+
|
|
27
|
+
from ertkit._warnings import ConvergenceWarning, DataWarning
|
|
28
|
+
from ertkit.results import InversionResult
|
|
29
|
+
from ertkit.ves.data import Sounding
|
|
30
|
+
from ertkit.ves.forward import _rhoa
|
|
31
|
+
from ertkit.ves.model import LayeredModel
|
|
32
|
+
|
|
33
|
+
__all__ = ["LayerFit", "invert_few_layer"]
|
|
34
|
+
|
|
35
|
+
_STEP = 1e-30 # complex step; any value far below sqrt(machine epsilon) works
|
|
36
|
+
_POOR_FIT_P_VALUE = 0.01
|
|
37
|
+
"""Goodness-of-fit p-value below which a fit is reported as poor."""
|
|
38
|
+
_SMALL_MISFIT_P_VALUE = 0.99
|
|
39
|
+
"""Goodness-of-fit p-value above which the errors look overestimated."""
|
|
40
|
+
_POORLY_DETERMINED_FACTOR = 10.0
|
|
41
|
+
"""Uncertainty factor above which a parameter is reported as poorly determined."""
|
|
42
|
+
_EXPLORE_EVALUATIONS = 30
|
|
43
|
+
"""Evaluations per parameter for each starting model; the best are then polished."""
|
|
44
|
+
_POLISH_EVALUATIONS = 200
|
|
45
|
+
"""Evaluations per parameter for polishing a start."""
|
|
46
|
+
_POLISHED_STARTS = 3
|
|
47
|
+
"""Most starts polished, best first, until one converges at the smallest misfit."""
|
|
48
|
+
_STALLED_CHI2 = 1e-3
|
|
49
|
+
"""A polished start whose chi2 falls by less than this over its whole run has
|
|
50
|
+
converged in misfit; it is only moving along a ridge of equivalent models."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, eq=False, kw_only=True, slots=True)
|
|
54
|
+
class LayerFit(InversionResult):
|
|
55
|
+
r"""Result of a few-layer inversion.
|
|
56
|
+
|
|
57
|
+
Attributes
|
|
58
|
+
----------
|
|
59
|
+
model : LayeredModel
|
|
60
|
+
The best-fitting model.
|
|
61
|
+
parameter_names : tuple of str
|
|
62
|
+
Names of the estimated parameters, in order: ``rho_1``, ...,
|
|
63
|
+
``rho_N``, ``h_1``, ..., ``h_{N-1}``, then ``shift_2``, ... for the
|
|
64
|
+
static-shift factors of the second and later MN segments.
|
|
65
|
+
std_log : ndarray
|
|
66
|
+
Linearized standard deviation of the natural logarithm of each
|
|
67
|
+
parameter; ``inf`` for parameters the data do not determine.
|
|
68
|
+
covariance : ndarray
|
|
69
|
+
Linearized covariance matrix of the log parameters; rows and columns
|
|
70
|
+
of undetermined parameters are ``inf`` on the diagonal and ``nan``
|
|
71
|
+
elsewhere.
|
|
72
|
+
correlation : ndarray
|
|
73
|
+
Linearized correlation matrix of the log parameters (``nan`` for
|
|
74
|
+
undetermined parameters).
|
|
75
|
+
depths : ndarray
|
|
76
|
+
Depths to the bottoms of the upper :math:`N - 1` layers, in meters.
|
|
77
|
+
depth_std : ndarray
|
|
78
|
+
Linearized standard deviations of ``depths``, in meters (``inf`` when
|
|
79
|
+
a thickness above is undetermined). When the standard deviation of a
|
|
80
|
+
log thickness exceeds about 0.5, the linearization is poor and these
|
|
81
|
+
values are only indicative.
|
|
82
|
+
static_shift : ndarray
|
|
83
|
+
Multiplicative static-shift factor of each MN segment, relative to the
|
|
84
|
+
first segment (whose factor is 1 by definition). All ones when shifts
|
|
85
|
+
were not estimated.
|
|
86
|
+
segment_mn2 : ndarray
|
|
87
|
+
MN/2 of each segment, in meters, in the order of ``static_shift``.
|
|
88
|
+
p_value : float
|
|
89
|
+
Probability of a misfit at least this large if the model and the error
|
|
90
|
+
model were right (:math:`\chi^2` distribution with :math:`N_d - p`
|
|
91
|
+
degrees of freedom).
|
|
92
|
+
n_starts : int
|
|
93
|
+
Number of starting models tried.
|
|
94
|
+
at_bound : tuple of str
|
|
95
|
+
Parameters that ended at their search bounds (see the Notes of
|
|
96
|
+
:func:`invert_few_layer`). The data do not resolve them, and their
|
|
97
|
+
uncertainty factors are not meaningful.
|
|
98
|
+
|
|
99
|
+
See Also
|
|
100
|
+
--------
|
|
101
|
+
ertkit.InversionResult : The attributes common to all results.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
model: LayeredModel
|
|
105
|
+
parameter_names: tuple[str, ...]
|
|
106
|
+
std_log: NDArray[np.float64]
|
|
107
|
+
covariance: NDArray[np.float64]
|
|
108
|
+
correlation: NDArray[np.float64]
|
|
109
|
+
depths: NDArray[np.float64]
|
|
110
|
+
depth_std: NDArray[np.float64]
|
|
111
|
+
static_shift: NDArray[np.float64]
|
|
112
|
+
segment_mn2: NDArray[np.float64]
|
|
113
|
+
p_value: float
|
|
114
|
+
n_starts: int
|
|
115
|
+
at_bound: tuple[str, ...]
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def factor(self) -> NDArray[np.float64]:
|
|
119
|
+
r"""Multiplicative uncertainty :math:`e^{s}` of each parameter.
|
|
120
|
+
|
|
121
|
+
A factor of 1.2 means the parameter is known within a factor of 1.2,
|
|
122
|
+
that is, multiplied or divided by 1.2 (one standard deviation,
|
|
123
|
+
linearized). Parameters the data do not determine give ``inf``.
|
|
124
|
+
"""
|
|
125
|
+
with np.errstate(over="ignore"):
|
|
126
|
+
return np.exp(self.std_log)
|
|
127
|
+
|
|
128
|
+
def _details(self) -> list[str]:
|
|
129
|
+
n = self.model.n_layers
|
|
130
|
+
factor = self.factor
|
|
131
|
+
lines = [
|
|
132
|
+
"",
|
|
133
|
+
(
|
|
134
|
+
f"{'layer':>5} {'resistivity (ohm m)':<21} {'thickness (m)':<20} "
|
|
135
|
+
"depth (m)"
|
|
136
|
+
),
|
|
137
|
+
]
|
|
138
|
+
for i in range(n):
|
|
139
|
+
rho = f"{self.model.resistivity[i]:9.4g} x/ {_factor(factor[i]):<8}"
|
|
140
|
+
if i < n - 1:
|
|
141
|
+
h = f"{self.model.thickness[i]:8.3g} x/ {_factor(factor[n + i]):<8}"
|
|
142
|
+
z = f"{self.depths[i]:9.3g} +- {self.depth_std[i]:.2g}"
|
|
143
|
+
else:
|
|
144
|
+
h, z = f"{'half-space':>10}", ""
|
|
145
|
+
lines.append(f"{i + 1:5d} {rho} {h:20} {z}".rstrip())
|
|
146
|
+
lines.append(f"p-value: {self.p_value:.3g}")
|
|
147
|
+
if "shift_2" in self.parameter_names:
|
|
148
|
+
shifts = ", ".join(
|
|
149
|
+
f"{s:.3g} (MN/2 = {mn2:g} m)"
|
|
150
|
+
for s, mn2 in zip(self.static_shift, self.segment_mn2, strict=True)
|
|
151
|
+
)
|
|
152
|
+
lines.append(f"static shifts: {shifts}")
|
|
153
|
+
if self.at_bound:
|
|
154
|
+
lines.append(f"at search bounds: {', '.join(self.at_bound)}")
|
|
155
|
+
return lines
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _factor(value: float) -> str:
|
|
159
|
+
"""Format an uncertainty factor (x/ means multiplied or divided by)."""
|
|
160
|
+
return f"{value:.3g}" if np.isfinite(value) else "inf"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _segments(mn2: NDArray[np.float64]) -> NDArray[np.intp]:
|
|
164
|
+
"""Index of the MN segment of each reading.
|
|
165
|
+
|
|
166
|
+
Readings with the same MN/2 form one segment, whatever their order in the
|
|
167
|
+
file; segments are numbered in order of first appearance.
|
|
168
|
+
"""
|
|
169
|
+
values, first, inverse = np.unique(mn2, return_index=True, return_inverse=True)
|
|
170
|
+
rank = np.empty(values.size, dtype=np.intp)
|
|
171
|
+
rank[np.argsort(first)] = np.arange(values.size)
|
|
172
|
+
return rank[inverse].astype(np.intp)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _linearized_covariance(
|
|
176
|
+
weighted: NDArray[np.float64],
|
|
177
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
178
|
+
r"""Covariance, standard deviations and correlations of the parameters.
|
|
179
|
+
|
|
180
|
+
Computed from the singular value decomposition of :math:`WJ` itself
|
|
181
|
+
rather than by inverting :math:`J^T W^2 J`, which would square the
|
|
182
|
+
condition number and hide poorly determined directions. Directions with
|
|
183
|
+
singular values below the numerical rank tolerance are undetermined, and
|
|
184
|
+
every parameter loading on them gets an infinite standard deviation.
|
|
185
|
+
"""
|
|
186
|
+
_, sv, vt = np.linalg.svd(weighted, full_matrices=False)
|
|
187
|
+
tol = sv[0] * max(weighted.shape) * np.finfo(float).eps
|
|
188
|
+
keep = sv > tol
|
|
189
|
+
cov = (vt[keep].T / sv[keep] ** 2) @ vt[keep]
|
|
190
|
+
unresolved = np.sum(vt[~keep] ** 2, axis=0) > 1e-8
|
|
191
|
+
std = np.sqrt(np.clip(np.diag(cov), 0.0, None))
|
|
192
|
+
std[unresolved] = np.inf
|
|
193
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
194
|
+
corr = cov / np.outer(std, std)
|
|
195
|
+
for arr in (cov, corr):
|
|
196
|
+
arr[unresolved, :] = np.nan
|
|
197
|
+
arr[:, unresolved] = np.nan
|
|
198
|
+
cov[unresolved, unresolved] = np.inf
|
|
199
|
+
corr[~unresolved, ~unresolved] = 1.0
|
|
200
|
+
return cov, std, corr
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _segment_mn2(
|
|
204
|
+
mn2: NDArray[np.float64], segment: NDArray[np.intp]
|
|
205
|
+
) -> NDArray[np.float64]:
|
|
206
|
+
"""MN/2 of each segment, in segment order."""
|
|
207
|
+
out = np.empty(int(segment.max()) + 1)
|
|
208
|
+
out[segment] = mn2
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _static_shift_hint(n_segments: int) -> str:
|
|
213
|
+
"""Advice for a poor fit of data with several MN segments."""
|
|
214
|
+
if n_segments < 2:
|
|
215
|
+
return ""
|
|
216
|
+
return (
|
|
217
|
+
"; if the curve steps where MN changes, estimate static shifts "
|
|
218
|
+
"(invert_few_layer(..., static_shift=True))"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _bound_advice(name: str, upper: bool, n_layers: int) -> str:
|
|
223
|
+
"""What a parameter at its search bound means, and what to do about it."""
|
|
224
|
+
kind, _, index = name.partition("_")
|
|
225
|
+
side = "upper" if upper else "lower"
|
|
226
|
+
if kind == "shift":
|
|
227
|
+
return f"{name} ({side} bound: a shift beyond a factor of 2 suggests a data problem)"
|
|
228
|
+
if kind == "h":
|
|
229
|
+
if upper:
|
|
230
|
+
return f"{name} (upper bound: the layer reaches below the data; consider fewer layers)"
|
|
231
|
+
return f"{name} (lower bound: the layer is too thin to resolve; consider fewer layers)"
|
|
232
|
+
if int(index) == n_layers:
|
|
233
|
+
limit = "lower" if upper else "upper"
|
|
234
|
+
return (
|
|
235
|
+
f"{name} ({side} bound: the data give only an {limit} limit on the "
|
|
236
|
+
"bottom resistivity; longer AB/2 would resolve it)"
|
|
237
|
+
)
|
|
238
|
+
product = "transverse resistance h * rho" if upper else "conductance h / rho"
|
|
239
|
+
return (
|
|
240
|
+
f"{name} ({side} bound: only the {product} of layer {index} may be "
|
|
241
|
+
"determined; consider fewer layers)"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _Problem:
|
|
246
|
+
"""Log-space forward model and Jacobian of a few-layer sounding."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, data: Sounding, n_layers: int, static_shift: bool) -> None:
|
|
249
|
+
self.n_layers = n_layers
|
|
250
|
+
self.ab2 = data.survey.ab2
|
|
251
|
+
self.mn2 = data.survey.mn2
|
|
252
|
+
self.segment = _segments(self.mn2)
|
|
253
|
+
self.n_shifts = int(self.segment.max()) if static_shift else 0
|
|
254
|
+
self.n_params = 2 * n_layers - 1 + self.n_shifts
|
|
255
|
+
self.d = data.log_data
|
|
256
|
+
self.s = data.log_std
|
|
257
|
+
|
|
258
|
+
def predict(
|
|
259
|
+
self, m: NDArray[np.complex128] | NDArray[np.float64]
|
|
260
|
+
) -> NDArray[np.complex128]:
|
|
261
|
+
n = self.n_layers
|
|
262
|
+
p = np.exp(m[: 2 * n - 1])
|
|
263
|
+
f = np.log(_rhoa(p[:n], p[n:], self.ab2, self.mn2).astype(complex))
|
|
264
|
+
if self.n_shifts:
|
|
265
|
+
shifts = np.concatenate([[0.0], m[2 * n - 1 :]])
|
|
266
|
+
f = f + shifts[self.segment]
|
|
267
|
+
return np.asarray(f)
|
|
268
|
+
|
|
269
|
+
def residual(self, m: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
270
|
+
return np.asarray((self.predict(m).real - self.d) / self.s)
|
|
271
|
+
|
|
272
|
+
def jacobian(self, m: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
273
|
+
"""Jacobian of the log data with respect to m, by complex steps."""
|
|
274
|
+
jac = np.empty((self.d.size, m.size))
|
|
275
|
+
for k in range(m.size):
|
|
276
|
+
bumped = m.astype(complex)
|
|
277
|
+
bumped[k] += 1j * _STEP
|
|
278
|
+
jac[:, k] = self.predict(bumped).imag / _STEP
|
|
279
|
+
return jac
|
|
280
|
+
|
|
281
|
+
def bounds(self, data: Sounding) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
282
|
+
"""Search bounds on the log parameters.
|
|
283
|
+
|
|
284
|
+
Resistivities within a factor of 1000 of the observed apparent
|
|
285
|
+
resistivities, thicknesses between 1/1000 of the smallest and 10 times
|
|
286
|
+
the largest AB/2, and static shifts within a factor of 2: the data
|
|
287
|
+
cannot resolve values beyond these.
|
|
288
|
+
"""
|
|
289
|
+
n = self.n_layers
|
|
290
|
+
rho_lo, rho_hi = np.log(data.rhoa.min() / 1e3), np.log(data.rhoa.max() * 1e3)
|
|
291
|
+
h_lo, h_hi = np.log(self.ab2.min() / 1e3), np.log(self.ab2.max() * 10.0)
|
|
292
|
+
lower = np.concatenate(
|
|
293
|
+
[
|
|
294
|
+
np.full(n, rho_lo),
|
|
295
|
+
np.full(n - 1, h_lo),
|
|
296
|
+
np.full(self.n_shifts, -np.log(2.0)),
|
|
297
|
+
]
|
|
298
|
+
)
|
|
299
|
+
upper = np.concatenate(
|
|
300
|
+
[
|
|
301
|
+
np.full(n, rho_hi),
|
|
302
|
+
np.full(n - 1, h_hi),
|
|
303
|
+
np.full(self.n_shifts, np.log(2.0)),
|
|
304
|
+
]
|
|
305
|
+
)
|
|
306
|
+
return lower, upper
|
|
307
|
+
|
|
308
|
+
def names(self) -> tuple[str, ...]:
|
|
309
|
+
n = self.n_layers
|
|
310
|
+
return (
|
|
311
|
+
tuple(f"rho_{i + 1}" for i in range(n))
|
|
312
|
+
+ tuple(f"h_{i + 1}" for i in range(n - 1))
|
|
313
|
+
+ tuple(f"shift_{j + 2}" for j in range(self.n_shifts))
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _random_start(
|
|
318
|
+
data: Sounding, n_layers: int, n_shifts: int, rng: np.random.Generator
|
|
319
|
+
) -> NDArray[np.float64]:
|
|
320
|
+
"""Log-uniform random model spanning the data's resistivities and depths."""
|
|
321
|
+
lo, hi = np.log(data.rhoa.min() / 3), np.log(data.rhoa.max() * 3)
|
|
322
|
+
log_rho = rng.uniform(lo, hi, n_layers)
|
|
323
|
+
# Depths of investigation are roughly AB/2 / 3 for Schlumberger soundings.
|
|
324
|
+
zmin, zmax = np.log(data.survey.ab2.min() / 3), np.log(data.survey.ab2.max() / 3)
|
|
325
|
+
depths = np.exp(np.sort(rng.uniform(zmin, zmax, n_layers - 1)))
|
|
326
|
+
log_h = np.log(np.diff(np.concatenate([[0.0], depths])))
|
|
327
|
+
return np.concatenate([log_rho, log_h, np.zeros(n_shifts)])
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def invert_few_layer(
|
|
331
|
+
data: Sounding,
|
|
332
|
+
n_layers: int,
|
|
333
|
+
*,
|
|
334
|
+
start: LayeredModel | None = None,
|
|
335
|
+
n_starts: int = 10,
|
|
336
|
+
static_shift: bool = False,
|
|
337
|
+
rng: int | np.random.Generator | None = None,
|
|
338
|
+
) -> LayerFit:
|
|
339
|
+
r"""Fit a layered model with a few layers to a sounding.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
data : Sounding
|
|
344
|
+
The data and their error model.
|
|
345
|
+
n_layers : int
|
|
346
|
+
Number of layers :math:`N`, including the bottom half-space.
|
|
347
|
+
start : LayeredModel, optional
|
|
348
|
+
Starting model. If given, only this start is used; otherwise
|
|
349
|
+
``n_starts`` random starting models are tried and the fit with the
|
|
350
|
+
smallest misfit is kept.
|
|
351
|
+
n_starts : int, optional
|
|
352
|
+
Number of random starting models. Default 10.
|
|
353
|
+
static_shift : bool, optional
|
|
354
|
+
Also estimate a multiplicative static-shift factor for every MN
|
|
355
|
+
segment after the first. Readings with the same MN/2 form a segment.
|
|
356
|
+
Default False.
|
|
357
|
+
rng : int, numpy.random.Generator or None, optional
|
|
358
|
+
Seed or generator for the starting models.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
LayerFit
|
|
363
|
+
|
|
364
|
+
Raises
|
|
365
|
+
------
|
|
366
|
+
TypeError
|
|
367
|
+
If ``data`` is not a :class:`Sounding`.
|
|
368
|
+
ValueError
|
|
369
|
+
If ``n_layers`` or ``n_starts`` is less than 1, if there are not more
|
|
370
|
+
data than parameters, if ``start`` has the wrong number of layers, or
|
|
371
|
+
if ``static_shift`` is set and an MN segment has a single reading.
|
|
372
|
+
|
|
373
|
+
Warns
|
|
374
|
+
-----
|
|
375
|
+
ConvergenceWarning
|
|
376
|
+
If the best fit did not meet the convergence criterion.
|
|
377
|
+
DataWarning
|
|
378
|
+
If the fit is poor (goodness-of-fit p-value below 0.01: the errors may
|
|
379
|
+
be underestimated, or the model needs more layers or static shifts),
|
|
380
|
+
if it is closer than the errors imply (p-value above 0.99: the errors
|
|
381
|
+
may be overestimated), if parameters end at their search bounds
|
|
382
|
+
(listed in ``LayerFit.at_bound``), or if other parameters are poorly
|
|
383
|
+
determined (uncertainty factor above 10).
|
|
384
|
+
|
|
385
|
+
Notes
|
|
386
|
+
-----
|
|
387
|
+
The parameters are searched within bounds beyond which the data cannot
|
|
388
|
+
resolve them: resistivities within a factor of 1000 of the observed
|
|
389
|
+
apparent resistivities, thicknesses between 1/1000 of the smallest and 10
|
|
390
|
+
times the largest AB/2, and static shifts within a factor of 2. Each
|
|
391
|
+
starting model runs for 30 function evaluations per parameter; the best
|
|
392
|
+
are then continued, for up to 200 evaluations per parameter each, until
|
|
393
|
+
one converges at the smallest misfit.
|
|
394
|
+
|
|
395
|
+
The linearized covariance of the log parameters is
|
|
396
|
+
:math:`(J^T W^2 J)^{-1}` with :math:`W = \mathrm{diag}(1/s_i)`, computed
|
|
397
|
+
from the singular values of :math:`WJ`. It describes the uncertainty well
|
|
398
|
+
when the parameters are well resolved. Equivalent layers show up as large
|
|
399
|
+
uncertainty factors, but a linearized uncertainty can still understate the
|
|
400
|
+
range of acceptable models; the equivalence analysis planned for release
|
|
401
|
+
0.2 samples that range directly.
|
|
402
|
+
|
|
403
|
+
Examples
|
|
404
|
+
--------
|
|
405
|
+
>>> import numpy as np
|
|
406
|
+
>>> from ertkit.ves import LayeredModel, Sounding, Survey, invert_few_layer
|
|
407
|
+
>>> truth = LayeredModel(resistivity=[150.0, 30.0, 800.0], thickness=[2.0, 15.0])
|
|
408
|
+
>>> survey = Survey.schlumberger(ab2=np.geomspace(2.5, 200.0, 22), mn2=0.5)
|
|
409
|
+
>>> data = Sounding.synthetic(truth, survey, rng=5)
|
|
410
|
+
>>> fit = invert_few_layer(data, n_layers=3, rng=0)
|
|
411
|
+
>>> fit.parameter_names
|
|
412
|
+
('rho_1', 'rho_2', 'rho_3', 'h_1', 'h_2')
|
|
413
|
+
"""
|
|
414
|
+
data_obj: object = data
|
|
415
|
+
if not isinstance(data_obj, Sounding):
|
|
416
|
+
raise TypeError(f"data must be a Sounding, got {type(data_obj).__name__}")
|
|
417
|
+
n_layers = operator.index(n_layers)
|
|
418
|
+
n_starts = operator.index(n_starts)
|
|
419
|
+
if n_layers < 1:
|
|
420
|
+
raise ValueError("n_layers must be at least 1")
|
|
421
|
+
if n_starts < 1:
|
|
422
|
+
raise ValueError("n_starts must be at least 1")
|
|
423
|
+
problem = _Problem(data, n_layers, static_shift)
|
|
424
|
+
segment_mn2 = _segment_mn2(problem.mn2, problem.segment)
|
|
425
|
+
if problem.n_shifts:
|
|
426
|
+
single = segment_mn2[np.bincount(problem.segment) < 2]
|
|
427
|
+
if single.size:
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"static shifts need at least 2 readings in each MN segment "
|
|
430
|
+
"(readings with the same MN/2); only 1 reading has MN/2 = "
|
|
431
|
+
f"{', '.join(f'{v:g}' for v in single)} m. Wenner and other "
|
|
432
|
+
"arrays whose MN grows with AB have no segments to shift."
|
|
433
|
+
)
|
|
434
|
+
if data.n_data <= problem.n_params:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
f"{data.n_data} data cannot determine {problem.n_params} parameters"
|
|
437
|
+
)
|
|
438
|
+
generator = np.random.default_rng(rng)
|
|
439
|
+
if start is not None:
|
|
440
|
+
if start.n_layers != n_layers:
|
|
441
|
+
raise ValueError(f"start has {start.n_layers} layers, expected {n_layers}")
|
|
442
|
+
starts = [
|
|
443
|
+
np.concatenate(
|
|
444
|
+
[
|
|
445
|
+
np.log(start.resistivity),
|
|
446
|
+
np.log(start.thickness),
|
|
447
|
+
np.zeros(problem.n_shifts),
|
|
448
|
+
]
|
|
449
|
+
)
|
|
450
|
+
]
|
|
451
|
+
else:
|
|
452
|
+
starts = [
|
|
453
|
+
_random_start(data, n_layers, problem.n_shifts, generator)
|
|
454
|
+
for _ in range(n_starts)
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
lower, upper = problem.bounds(data)
|
|
458
|
+
margin = 1e-6 * (upper - lower)
|
|
459
|
+
|
|
460
|
+
# scipy passes extra arguments to these callables, so they accept them.
|
|
461
|
+
def residual(
|
|
462
|
+
m: NDArray[np.float64], *_: object, **__: object
|
|
463
|
+
) -> NDArray[np.float64]:
|
|
464
|
+
return problem.residual(m)
|
|
465
|
+
|
|
466
|
+
def jacobian(
|
|
467
|
+
m: NDArray[np.float64], *_: object, **__: object
|
|
468
|
+
) -> NDArray[np.float64]:
|
|
469
|
+
return problem.jacobian(m) / problem.s[:, np.newaxis]
|
|
470
|
+
|
|
471
|
+
# Tolerances: 1e-8 in the log parameters is far below any uncertainty that
|
|
472
|
+
# matters, and tighter values make starts on an equivalence ridge run out
|
|
473
|
+
# of evaluations without improving the fit.
|
|
474
|
+
def solve(x0: NDArray[np.float64], max_nfev: int) -> optimize.OptimizeResult:
|
|
475
|
+
return optimize.least_squares(
|
|
476
|
+
residual,
|
|
477
|
+
np.clip(x0, lower + margin, upper - margin),
|
|
478
|
+
jac=jacobian,
|
|
479
|
+
bounds=(lower, upper),
|
|
480
|
+
method="trf",
|
|
481
|
+
x_scale="jac",
|
|
482
|
+
ftol=1e-10,
|
|
483
|
+
xtol=1e-8,
|
|
484
|
+
gtol=1e-10,
|
|
485
|
+
max_nfev=max_nfev,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def at_minimum(fits: list[optimize.OptimizeResult], done: list[bool]) -> bool:
|
|
489
|
+
"""Whether a start converged at (within round-off of) the smallest misfit."""
|
|
490
|
+
cost = min(f.cost for f in fits)
|
|
491
|
+
return any(
|
|
492
|
+
d and f.cost <= cost * (1 + 1e-8) + 1e-12
|
|
493
|
+
for f, d in zip(fits, done, strict=True)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Every start gets a short run, and only the best are continued: starts
|
|
497
|
+
# that crawl along an equivalence ridge would otherwise use most of the
|
|
498
|
+
# time. A polished start that runs out of evaluations while its misfit
|
|
499
|
+
# stays put has converged in misfit and is only moving along the ridge;
|
|
500
|
+
# otherwise the next best are polished until one converges.
|
|
501
|
+
fits = [solve(x0, _EXPLORE_EVALUATIONS * problem.n_params) for x0 in starts]
|
|
502
|
+
n_evaluations = [int(f.nfev) for f in fits]
|
|
503
|
+
done = [bool(f.success) for f in fits]
|
|
504
|
+
messages = [str(f.message) for f in fits]
|
|
505
|
+
for i in np.argsort([f.cost for f in fits])[:_POLISHED_STARTS]:
|
|
506
|
+
if at_minimum(fits, done):
|
|
507
|
+
break
|
|
508
|
+
if fits[i].status != 0: # stopped for a reason other than the budget
|
|
509
|
+
continue
|
|
510
|
+
budget = _POLISH_EVALUATIONS * problem.n_params
|
|
511
|
+
polished = solve(fits[i].x, budget)
|
|
512
|
+
n_evaluations[i] += int(polished.nfev)
|
|
513
|
+
decrease = 2.0 * (fits[i].cost - polished.cost)
|
|
514
|
+
if polished.cost <= fits[i].cost:
|
|
515
|
+
fits[i] = polished
|
|
516
|
+
messages[i] = str(polished.message)
|
|
517
|
+
done[i] = bool(polished.success)
|
|
518
|
+
if not done[i] and polished.nfev >= budget and decrease < _STALLED_CHI2:
|
|
519
|
+
done[i] = True
|
|
520
|
+
messages[i] = (
|
|
521
|
+
f"chi2 changed by less than {_STALLED_CHI2:g} in "
|
|
522
|
+
f"{int(polished.nfev)} evaluations; the parameters move only "
|
|
523
|
+
"along a ridge of equivalent models"
|
|
524
|
+
)
|
|
525
|
+
i_best = int(np.argmin([f.cost for f in fits]))
|
|
526
|
+
best = fits[i_best]
|
|
527
|
+
converged = at_minimum(fits, done)
|
|
528
|
+
if not converged:
|
|
529
|
+
warnings.warn(
|
|
530
|
+
f"the best fit did not converge: {messages[i_best]}",
|
|
531
|
+
ConvergenceWarning,
|
|
532
|
+
stacklevel=2,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
m = best.x
|
|
536
|
+
n = n_layers
|
|
537
|
+
names = problem.names()
|
|
538
|
+
tol = 1e-4 * (upper - lower)
|
|
539
|
+
at_bound = tuple(
|
|
540
|
+
name
|
|
541
|
+
for name, value, lo, hi, t in zip(names, m, lower, upper, tol, strict=True)
|
|
542
|
+
if value <= lo + t or value >= hi - t
|
|
543
|
+
)
|
|
544
|
+
if at_bound:
|
|
545
|
+
advice = [
|
|
546
|
+
_bound_advice(name, value >= hi - t, n)
|
|
547
|
+
for name, value, hi, t in zip(names, m, upper, tol, strict=True)
|
|
548
|
+
if name in at_bound
|
|
549
|
+
]
|
|
550
|
+
warnings.warn(
|
|
551
|
+
"parameters at their search bounds, not resolved by the data: "
|
|
552
|
+
+ "; ".join(advice),
|
|
553
|
+
DataWarning,
|
|
554
|
+
stacklevel=2,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
chi2 = float(2.0 * best.cost)
|
|
558
|
+
dof = data.n_data - problem.n_params
|
|
559
|
+
p_value = float(stats.chi2.sf(chi2, dof))
|
|
560
|
+
if p_value < _POOR_FIT_P_VALUE:
|
|
561
|
+
hint = "" if static_shift else _static_shift_hint(segment_mn2.size)
|
|
562
|
+
warnings.warn(
|
|
563
|
+
f"poor fit: chi2 = {chi2:.1f} for {dof} degrees of freedom "
|
|
564
|
+
f"(p = {p_value:.2g}); the errors may be underestimated, or more "
|
|
565
|
+
f"layers may be needed{hint}. The uncertainties are then too small: "
|
|
566
|
+
f"the standard deviations should be about sqrt(chi2/{dof}) = "
|
|
567
|
+
f"{np.sqrt(chi2 / dof):.2g} times larger.",
|
|
568
|
+
DataWarning,
|
|
569
|
+
stacklevel=2,
|
|
570
|
+
)
|
|
571
|
+
elif p_value > _SMALL_MISFIT_P_VALUE:
|
|
572
|
+
warnings.warn(
|
|
573
|
+
f"the fit is closer than the errors imply: chi2 = {chi2:.1f} for "
|
|
574
|
+
f"{dof} degrees of freedom (p = {p_value:.2g}); the errors may be "
|
|
575
|
+
"overestimated, and the uncertainty factors then too large",
|
|
576
|
+
DataWarning,
|
|
577
|
+
stacklevel=2,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
weighted = problem.jacobian(m) / problem.s[:, np.newaxis]
|
|
581
|
+
covariance, std_log, correlation = _linearized_covariance(weighted)
|
|
582
|
+
poor = tuple(
|
|
583
|
+
name
|
|
584
|
+
for name, s in zip(names, std_log, strict=True)
|
|
585
|
+
if s >= np.log(_POORLY_DETERMINED_FACTOR) and name not in at_bound
|
|
586
|
+
)
|
|
587
|
+
if poor:
|
|
588
|
+
warnings.warn(
|
|
589
|
+
f"poorly determined parameters (uncertainty factor above "
|
|
590
|
+
f"{_POORLY_DETERMINED_FACTOR:g}): {', '.join(poor)}. Other values "
|
|
591
|
+
"fit the data equally well (equivalence; see LayerFit.correlation); "
|
|
592
|
+
"consider fewer layers.",
|
|
593
|
+
DataWarning,
|
|
594
|
+
stacklevel=2,
|
|
595
|
+
)
|
|
596
|
+
resistivity, thickness = np.exp(m[:n]), np.exp(m[n : 2 * n - 1])
|
|
597
|
+
# Depth z_i = h_1 + ... + h_i; its gradient with respect to ln h_j is h_j (j <= i).
|
|
598
|
+
grad = np.tril(np.ones((n - 1, n - 1))) * thickness[np.newaxis, :]
|
|
599
|
+
h_std = std_log[n : 2 * n - 1]
|
|
600
|
+
finite_h = np.isfinite(h_std)
|
|
601
|
+
cov_h = np.where(
|
|
602
|
+
np.outer(finite_h, finite_h), covariance[n : 2 * n - 1, n : 2 * n - 1], 0.0
|
|
603
|
+
)
|
|
604
|
+
depth_std = np.sqrt(np.clip(np.einsum("ij,jk,ik->i", grad, cov_h, grad), 0.0, None))
|
|
605
|
+
depth_std[np.cumsum(~finite_h) > 0] = np.inf
|
|
606
|
+
shifts = np.exp(np.concatenate([[0.0], m[2 * n - 1 :]]))
|
|
607
|
+
if not problem.n_shifts:
|
|
608
|
+
shifts = np.ones(int(problem.segment.max()) + 1)
|
|
609
|
+
|
|
610
|
+
predicted = problem.predict(m).real
|
|
611
|
+
return LayerFit(
|
|
612
|
+
predicted=np.exp(predicted),
|
|
613
|
+
residuals=(data.log_data - predicted) / data.log_std,
|
|
614
|
+
chi2=chi2,
|
|
615
|
+
chi2_target=float(dof),
|
|
616
|
+
n_data=data.n_data,
|
|
617
|
+
converged=converged,
|
|
618
|
+
stop_reason=messages[i_best],
|
|
619
|
+
n_iterations=n_evaluations[i_best],
|
|
620
|
+
model=LayeredModel(resistivity=resistivity, thickness=thickness),
|
|
621
|
+
parameter_names=names,
|
|
622
|
+
std_log=std_log,
|
|
623
|
+
covariance=covariance,
|
|
624
|
+
correlation=correlation,
|
|
625
|
+
depths=np.cumsum(thickness),
|
|
626
|
+
depth_std=depth_std,
|
|
627
|
+
static_shift=shifts,
|
|
628
|
+
segment_mn2=segment_mn2,
|
|
629
|
+
p_value=p_value,
|
|
630
|
+
n_starts=len(starts),
|
|
631
|
+
at_bound=at_bound,
|
|
632
|
+
)
|