6.3.10. Recovery, residual, and OOD diagnostics#
Loss functions for scientific inversion shapes what a network is trained to minimize;
pycsamt.ai.validation is where the result gets checked
afterwards, by someone other than the optimizer. It splits that
checking into four independent areas rather than one aggregate score:
a model-space metric for known-truth synthetic recovery
(recovery), a
response-space metric broken down by where it comes from
(residuals), whether a declared
predictive uncertainty deserves trust
(calibration), and an
out-of-distribution diagnostic for inputs the training
support never covered (ood). Plotting
stays in pycsamt.ai.plot; every number a validation figure
draws comes from here. The older, still-active AI inversion validation
page documents Inv2DAgent/Inv3DAgent’s own separate,
built-in validation protocol; this package is the newer, modular
library described in Architecture roadmap, and — as the closing section
below shows — the two are already partly connected rather than
entirely separate.
The public API is small enough to map explicitly. Report classes are frozen dataclasses: their numerical arrays are read-only, which prevents a plotting step from silently changing evidence after it has been computed.
Area |
Functions |
Returned evidence |
|---|---|---|
Known-truth recovery |
|
|
Forward-response fit |
|
|
Predictive uncertainty |
|
|
Training-support screening |
|
These outputs are complementary. Recovery asks whether a known synthetic earth was reconstructed; response residuals ask whether its forward response fits observations; calibration asks whether stated intervals attain their coverage; OOD screening asks whether applying the predictor was supported in the first place. None is a surrogate for another.
6.3.10.1. Scoring recovery when the truth is known#
A field survey has no ground truth to compare against, but a
synthetic realization from generate_2d_maxwell_dataset()
does, which is exactly what makes
recovery_report() possible:
masked RMSE and MAE reusing model’s own
reductions, an \(R^2\) against the true section’s own variance,
a windowed structural similarity index, and a per-depth breakdown.
Standing in for a trained network’s prediction with one of this
package’s own realizations, lightly perturbed and made deliberately
worse at the bottom row:
For valid model cells \(\mathcal V\), prediction \(\hat m_i\), and known truth \(m_i\), the three scalar recovery summaries are
Use one model parameterization consistently on both sides. In the example
that parameter is \(m=\log_{10}\rho\), so an RMSE of 0.1 means error in
log-resistivity, not 0.1 \(\Omega\,\mathrm m\). A factor-of-ten
resistivity error corresponds to one log unit. The valid mask combines the
explicit valid argument with finite cells in both arrays; a depth row
with no valid cell is retained as nan rather than being reported as zero.
>>> import numpy as np
>>> from pycsamt.ai.geology import GeologyGrid
>>> from pycsamt.ai.training.dataset2d import (
... Maxwell2DDatasetConfig, generate_2d_maxwell_dataset,
... )
>>> from pycsamt.ai.validation import recovery_report
>>> grid = GeologyGrid.regular_2d(nx=10, nz=8, dx_m=300, dz_m=150)
>>> config = Maxwell2DDatasetConfig(
... dataset_id="sv-demo", grid=grid,
... correlation_length_x_m=(600.0, 1200.0),
... correlation_length_z_m=(200.0, 400.0),
... frequencies_hz=np.logspace(0, 4, 12),
... station_x_m=list(np.linspace(500.0, 2500.0, 10)),
... n_realizations=6, seed=7,
... log_resistivity_mean=2.0, log_resistivity_std=0.4,
... validation_fraction=0.0, test_fraction=0.0,
... )
>>> dataset = generate_2d_maxwell_dataset(config)
>>> true_log_rho = np.log10(dataset.samples[0].resistivity_ohm_m)
>>> rng = np.random.default_rng(11)
>>> pred_log_rho = true_log_rho + rng.normal(scale=0.08, size=true_log_rho.shape)
>>> pred_log_rho[-1, :] += 0.35 # a network that degrades at depth
>>> report = recovery_report(pred_log_rho, true_log_rho, compute_ssim=True, ssim_window=3)
>>> round(report.rmse, 4), round(report.mae, 4), round(report.r2, 4)
(0.1533, 0.0971, 0.8531)
>>> round(report.ssim, 4)
0.8553
>>> np.round(report.depth_rmse, 4)
array([0.0728, 0.0538, 0.0872, 0.0522, 0.0717, 0.0793, 0.0411, 0.3954])
The global RMSE alone (0.153) hides exactly what
report.depth_rmse shows plainly: seven of eight layers sit near
0.05-0.09, and the bottom layer alone is at 0.395 — the injected
depth-dependent bias would be invisible in one aggregate number.
structural_similarity(), used
inside report.ssim above, follows the windowed
luminance/contrast/structure formulation of Wang et al. (2004),
with \(\mu\), \(\sigma^2\), and \(\sigma_{xy}\) the
windowed mean, variance, and covariance and \(c_1\), \(c_2\)
small stabilizing constants proportional to the data range — it has
no defined masking rule, so it is skipped (ssim=None in
RecoveryReport) whenever the
grid is partially masked or smaller than the requested window, rather
than silently computed over an incomplete comparison.
depth_profile_rmse() and
depth_profile_mae() are also
available standalone, for a report that only needs the per-layer
breakdown without the rest.
6.3.10.2. Breaking a response residual down by where it comes from#
Recovery only applies where a true model exists.
residuals instead breaks
response’s complex-impedance residual down
by station, frequency, and component — the diagnostic that still
works on a field survey with no synthetic truth at all, since it
compares a forward-simulated response against an observed one
rather than a predicted model against a true one. A residual that is
large everywhere means something different from one localized to a
handful of stations, and only the second shape is visible once it is
broken down:
>>> from pycsamt.ai.validation import response_residual_report
>>> rng = np.random.default_rng(5)
>>> n_sta, n_freq, n_comp = 4, 3, 2
>>> z_true = (
... rng.normal(scale=40, size=(n_sta, n_freq, n_comp))
... + 1j * rng.normal(scale=40, size=(n_sta, n_freq, n_comp))
... + (60 + 40j)
... )
>>> z_pred = z_true.copy()
>>> z_pred[2:, :, 1] += (15 + 10j) # bias only zyx at stations S3/S4
>>> report = response_residual_report(
... z_pred, z_true, kind="l2",
... station_names=["S1", "S2", "S3", "S4"],
... frequencies_hz=[1.0, 10.0, 100.0],
... components=["zxy", "zyx"],
... )
>>> report.overall.value
81.25000000000001
>>> np.round(report.by_station, 3)
array([ 0. , 0. , 162.5, 162.5])
>>> np.round(report.by_frequency, 3)
array([81.25, 81.25, 81.25])
>>> np.round(report.by_component, 3)
array([ 0. , 162.5])
The overall value of 81.25 gives no hint that the true residual is
zero at two of four stations; by_station and by_component
localize it exactly to S3/S4 and zyx, while
by_frequency stays flat because the injected bias does not depend
on frequency — three different projections of the same underlying
array, each answering a different question about the same misfit.
response_residual_report_from_contracts()
skips the manual array handling the same way
response_loss_from_contracts() does
(see Loss functions for scientific inversion), taking a
ForwardResult and a
SurveyData directly and refusing
to run on station, component, or frequency sets that are not
identically ordered.
With observed standard error \(s_{sfc}\) at station \(s\), frequency
\(f\), and component \(c\), the kind="l2" cell penalty and its
global reduction are
where \(v_{sfc}\) is the combined finite-data and validity mask. The
reported overall.value is \(\bar q\), a mean squared normalized
residual, not its square root. When errors=None, the division by
\(s_{sfc}\) is omitted and the result carries squared impedance units;
such an unnormalized value should not be compared between surveys with
different impedance scales. Axis summaries apply the same masked mean while
retaining one axis, so an empty station or frequency becomes nan.
6.3.10.3. Checking whether declared uncertainty deserves trust#
A network’s declared confidence is only useful if it is honest.
reliability_curve() checks
that directly: at each of several nominal confidence levels, what
fraction of a held-out calibration set’s true values actually
fall inside the predicted mean +/- z(level) * std interval — the
same Gaussian parameterization
gaussian_nll_loss() trains
against, so a network trained with that loss can be checked with the
metric that matches it. Two models with identical predicted means but
different declared standard deviations make the point concrete: one
whose declared spread matches the data’s real scatter, and one that
is simply overconfident:
>>> from pycsamt.ai.validation import reliability_curve
>>> rng = np.random.default_rng(21)
>>> true = rng.normal(loc=0.0, scale=1.0, size=2000)
>>> mean = np.zeros(2000)
>>> curve_ok = reliability_curve(true, mean, np.ones(2000), levels=[0.5, 0.8, 0.9, 0.95])
>>> np.round(curve_ok.coverage, 3)
array([0.514, 0.821, 0.908, 0.961])
>>> round(curve_ok.calibration.value, 5), curve_ok.sharpness
(0.0002, 1.0)
>>> curve_bad = reliability_curve(true, mean, np.full(2000, 0.5), levels=[0.5, 0.8, 0.9, 0.95])
>>> np.round(curve_bad.coverage, 3)
array([0.27 , 0.496, 0.606, 0.681])
>>> round(curve_bad.calibration.value, 5), curve_bad.sharpness
(0.07603, 0.5)
Sharpness alone would call the second model better: a declared
standard deviation of 0.5 is a tighter, more confident interval than
1.0. Its coverage tells the opposite story — a nominal 90% interval
only actually contains the true value 61% of the time, because the
declared spread understates the data’s real scatter by exactly the
factor it was shrunk by.
predictive_sharpness() is
therefore documented, and should be read, as meaningful only
alongside good calibration, never as a quality signal by itself.
For a two-sided Gaussian interval at nominal level \(\alpha\), empirical coverage and the default squared calibration penalty are
Here \(\Phi^{-1}\) is the standard-normal quantile and \(K\) is the number of requested levels. Equation (4) explains why one 90% coverage number is insufficient: a model can happen to cross the ideal line once while being systematically miscalibrated elsewhere. The full curve exposes that shape. It also explains why this check belongs on held-out truth—using training targets would measure confidence on data already used to fit the predictor.
6.3.10.4. Flagging predictions the training distribution never covered#
None of the checks above say anything about an input a network was
never trained near in the first place.
ood_score() measures that directly
against a reference set of training feature vectors, either by
Mahalanobis distance from the reference mean and covariance,
which assumes an elliptical reference distribution and needs more
reference samples than features to invert
\(\boldsymbol\Sigma\), or by plain Euclidean distance to the
\(k\)-th nearest reference point, which makes no distributional
assumption at all.
flag_out_of_distribution() turns
either score into a flag by comparing against a quantile of the
reference set’s own leave-one-out self-scores — “how unusual is a
typical reference point” — rather than an arbitrary fixed number.
Using each realization’s own depth- and lateral-roughness (the
standard deviation of its first difference along each axis) as a
two-feature summary of geological character, a realization from the
same Maxwell2DDatasetConfig
used above sits well inside the reference set, while one generated
with correlation lengths ten times shorter — a visibly rougher,
structurally different field — does not:
>>> from pycsamt.ai.validation import flag_out_of_distribution
>>> reference = np.array([
... [np.diff(np.log10(s.resistivity_ohm_m), axis=0).std(),
... np.diff(np.log10(s.resistivity_ohm_m), axis=1).std()]
... for s in dataset.samples
... ])
>>> short_config = Maxwell2DDatasetConfig(
... dataset_id="sv-ood-shortcorr", grid=grid,
... correlation_length_x_m=(60.0, 90.0),
... correlation_length_z_m=(20.0, 40.0),
... frequencies_hz=np.logspace(0, 4, 12),
... station_x_m=list(np.linspace(500.0, 2500.0, 10)),
... n_realizations=1, seed=100,
... log_resistivity_mean=2.0, log_resistivity_std=0.4,
... validation_fraction=0.0, test_fraction=0.0,
... )
>>> short_dataset = generate_2d_maxwell_dataset(short_config)
>>> short_rho = np.log10(short_dataset.samples[0].resistivity_ohm_m)
>>> x = np.array([
... reference[0],
... [np.diff(short_rho, axis=0).std(), np.diff(short_rho, axis=1).std()],
... ])
>>> report = flag_out_of_distribution(x, reference, method="mahalanobis", quantile=0.9)
>>> np.round(report.scores, 3)
array([ 0.952, 33.178])
>>> round(report.threshold, 3)
1.524
>>> report.flagged.tolist()
[False, True]
A Mahalanobis distance of 33.2 against a threshold of 1.5 is not a
subtle case — the short-correlation field’s roughness lies far
outside anything the six reference realizations sampled, which is
exactly the situation Domain-gap and noise simulation warns a network never sees
during training and flag_out_of_distribution() exists to catch
before a confident-looking, unsupported map reaches a report.
The automatically inferred OOD threshold is likewise reproducible:
where \(Q_q\) is the requested reference-score quantile and \(\mathcal R_{-j}\) denotes leave-one-out reference support for k-NN. The Mahalanobis implementation uses full-reference self-scores instead. This is a screening rule, not a probability that a geological interpretation is wrong: the result depends on the chosen features, reference population, distance, and quantile. Those four choices therefore belong in the experiment record.
6.3.10.5. Reading the four diagnostics together#
The following compact audit uses the public validation functions on one deterministic synthetic scenario. It is intentionally not collapsed into a single score because each panel answers a different scientific question.
Four independent views of model recovery, response fit, uncertainty calibration, and training-domain support.#
The upper-left profile shows that the deeper bias increases both RMSE and MAE even though the section-wide SSIM remains high; structural similarity has not cancelled a depth-dependent amplitude error. The response heat map then localizes a different failure: a compact station-frequency block is poorly fit, while the lowest-frequency rows carry a smaller survey-wide mismatch. Neither pattern is recoverable from the global response mean alone.
In the lower-left panel, reducing predictive standard deviation makes the red model sharper but moves its reliability curve far below the diagonal. Its intervals are narrower because it is overconfident, not because it predicts better. Finally, the lower-right panel flags five feature vectors: the three visibly extreme points and two less obvious covariance-normalized departures. That is not a plotting error. Mahalanobis distance follows the reference covariance, so a point can look close in ordinary Euclidean distance while lying across a narrow direction of the reference ellipse. Green points are supported in these two selected features; they are not certified geologically correct. Taken together, the panels show why passing one diagnostic cannot compensate for failing another.
View scientific-validation figure source codeClick to inspect and copy the complete code
1def make_scientific_validation_anatomy() -> None:
2 """Compare the four independent validation views on one synthetic audit."""
3 from scipy.ndimage import gaussian_filter
4
5 rng = np.random.default_rng(314)
6 nz, nx = 28, 48
7 z = np.linspace(0.0, 1.0, nz)[:, None]
8 x = np.linspace(-1.0, 1.0, nx)[None, :]
9 truth = 2.25 + 0.55 * z
10 truth = truth - 1.15 * np.exp(-((x + 0.20) / 0.24) ** 2
11 - ((z - 0.58) / 0.16) ** 2)
12 truth = truth + 0.45 * np.exp(-((x - 0.55) / 0.20) ** 2
13 - ((z - 0.30) / 0.12) ** 2)
14 prediction = gaussian_filter(truth, sigma=(1.1, 1.6))
15 prediction += rng.normal(0.0, 0.035, truth.shape)
16 prediction[z[:, 0] > 0.78] += 0.18
17 recovery = recovery_report(prediction, truth, ssim_window=7)
18
19 n_station, n_frequency = 14, 20
20 frequency = np.logspace(-1, 3, n_frequency)
21 observed = np.ones((n_station, n_frequency, 2), dtype=complex) * (70 + 45j)
22 predicted = observed.copy()
23 predicted[8:12, 6:14, 1] += 9 + 7j
24 predicted[:, :3, :] += 3 + 2j
25 error = np.full(observed.shape, 5.0)
26 residual = response_residual_report(
27 predicted, observed, errors=error, kind="l2",
28 station_names=[f"S{i + 1:02d}" for i in range(n_station)],
29 frequencies_hz=frequency, components=("zxy", "zyx"),
30 )
31 _finish_scientific_validation_anatomy(
32 rng, nz, n_station, frequency, observed, predicted, error,
33 recovery, residual,
34 )
6.3.10.6. Turning diagnostics into an acceptance decision#
Validation becomes an acceptance test only after thresholds are frozen before
examining the candidate result. AcceptanceCriterion
provides that boundary; Reproducible experiment configuration explains how the criteria are stored
with the complete experiment configuration. The metric mapping can be built
directly from the immutable reports:
>>> from pycsamt.ai.experiments import AcceptanceCriterion
>>> criteria = (
... AcceptanceCriterion("test.model_rmse", "<=", 0.20),
... AcceptanceCriterion("test.response_msne", "<=", 2.00),
... AcceptanceCriterion("test.calibration_mse", "<=", 0.01),
... AcceptanceCriterion("field.ood_fraction", "<=", 0.10),
... )
>>> observed = {
... "test.model_rmse": 0.1533,
... "test.response_msne": 81.25,
... "test.calibration_mse": 0.0002,
... "field.ood_fraction": 0.04,
... }
>>> outcomes = {c.metric: c.evaluate(observed[c.metric]) for c in criteria}
>>> outcomes
{'test.model_rmse': True, 'test.response_msne': False, 'test.calibration_mse': True, 'field.ood_fraction': True}
>>> all(outcomes.values())
False
Three passing checks do not outvote the failed response criterion. The
candidate is rejected because the gate is conjunctive, as formalized in
(1). The numerical thresholds above demonstrate the
API; they are not pyCSAMT defaults and must be justified from the survey’s
error model, synthetic benchmark suite, and intended decision risk. In a real
experiment, pass the same criteria to
ExperimentConfig so missing metrics also make
the gate incomplete rather than disappearing from the decision.
6.3.10.7. What actually reaches an agent’s result today#
recovery_report() is not only
documented here — it already runs inside Inv2DAgent’s
physics="mt2d" path. When a held-out synthetic test or validation
partition is available, the agent predicts on it, compares against
its known-truth resistivity with exactly this function, and folds the
averaged RMSE/MAE/\(R^2\) into its result, precisely because a
field survey has no ground truth to run the same check against — a
synthetic partition is the only place this class of check can run at
all. residuals,
calibration, and
ood are not wired into that same
automatic result yet; they are available today the same way
Loss functions for scientific inversion’s response, boundary, and uncertainty terms are —
correct, tested, and ready to be called explicitly on a
ForwardResult,
SurveyData, or reference feature
set, rather than produced automatically by every inversion run.