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/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
+ )