11.27.8. Learned Uncertainty Calibration#

Machine-Learning Quality Scoring answers “is this (station, frequency) cell trustworthy?” with a score in [0, 1]. It does not answer “how large should the error bar on this cell be?” – yet that second question is exactly what an inversion error floor needs, and today pyCSAMT, like most MT software, uses whatever z_err field the processing software wrote, or a flat percentage-of-|Z| floor. Both are frequently miscalibrated: field error propagation is often too optimistic in quiet bands and too pessimistic near noise spikes. UncertaintyCalibrator re-estimates that error directly, regressing a smoothed, internally consistent fractional error from the same kind of per-cell diagnostic features EMQCScorer already extracts.

There is no pre-existing “correct error bar” to train against, the way Machine-Learning Quality Scoring’s rule-based labels or Learned Dimensionality Classification’s skew/ellipticity thresholds exist. UncertaintyCalibrator trains in recalibration mode: the regression target is the field-processing z_err itself, expressed as a fractional error rather than an absolute value,

(1)#\[e_\text{frac} = \frac{|z_\text{err}|}{|Z|}\]

so it stays comparable across stations and frequencies with very different \(|Z|\). On L18PLT the raw z_err column itself spans three decades (1.3 to 1625) purely from \(|Z|\) scale, while \(e_\text{frac}\) is a tight, physically meaningful range:

>>> from pycsamt.ai.processing import build_uncertainty_features_table
>>> from pycsamt.emtools import ensure_sites
>>> sites = ensure_sites("data/AMT/WILLY_DATA/L18PLT", recursive=True)
>>> feats = build_uncertainty_features_table(sites)
>>> len(feats), feats["station"].nunique()
(1484, 28)
>>> feats["z_err_frac"].min(), feats["z_err_frac"].max(), \
...     feats["z_err_frac"].median()
(0.0152, 0.6702, 0.0547)

The network only ever sees four diagnostic features – Swift skew, off-diagonal amplitude asymmetry, and both off-diagonal phases – so its output is a smoothed re-estimate driven by signal quality, not a copy of z_err_frac.

Note

EMQCScorer’s fifth feature, snr, is deliberately not one of those four, even though build_uncertainty_features_table still reports it as a diagnostic column. snr is built from the exact same pooled amplitude/error terms as z_err_frac – verified on this data, corr(1/snr, z_err_frac) = 1 - 2e-16, identical to floating-point precision – so feeding it in would let the network “recalibrate” by trivially inverting one input column rather than genuinely re-estimating error from signal shape. This is a target-leakage pitfall – a feature that deterministically encodes the answer – a different failure mode from the degenerate-feature pitfall caught in DistortionTypeClassifier (Groom-Bailey’s gain column carried no information at all, always exactly 1.0), but both were caught the same way: by checking a candidate feature’s actual behaviour against real data – its variance, or here its correlation with the target – before trusting it.

11.27.8.1. Fitting and a held-out quantitative check#

A field z_err is itself only an estimate, so validating the calibrator means checking how well it predicts held-out cells’ z_err_frac – the same masked-reconstruction logic as Learned Gap Filling’s validation, but simpler here since there is only one target unit to track rather than several feature components:

>>> import numpy as np
>>> from pycsamt.ai.processing import UncertaintyCalibrator
>>> rng = np.random.default_rng(0)
>>> idx = rng.permutation(len(feats))
>>> n_val = int(0.2 * len(feats))
>>> val_df = feats.iloc[idx[:n_val]].reset_index(drop=True)
>>> train_df = feats.iloc[idx[n_val:]].reset_index(drop=True)
>>> cal = UncertaintyCalibrator(hidden=(32, 16))
>>> cal.fit(train_df, epochs=150, seed=0, verbose=False)
UncertaintyCalibrator(n_features=4, torch, fitted)
>>> y_true = val_df["z_err_frac"].to_numpy()
>>> y_pred = cal.transform(val_df)
>>> rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
>>> rmse
0.0298

Against a “predict the median” baseline (RMSE 0.0369) that is a real, if modest, ~19% error reduction – \(R^2 = 0.317\) over the 296 held-out cells, meaning the four diagnostic features explain roughly a third of the field error’s cell-to-cell variance. The rest is plausibly genuine measurement-specific noise no station-level feature can predict, not a sign the calibrator has failed.

Warning

As with every network-based estimator in this package, seed fixes the held-out split but not PyTorch’s own weight- initialization RNG – rerunning the block above reproduces the same protocol, not bit-identical numbers. See the equivalent caveat in Learned Spectral Denoising.

../../_images/uncertainty_summary.png

plot_uncertainty_summary() – (a) the station x frequency calibrated-error map (log colour scale); (b) median fractional error per station, original versus calibrated; (c) the held-out validation scatter; (d) the training curve.#

Panel (b) shows the calibration’s main visible effect: the station-to-station spread shrinks – the standard deviation of the 28 per-station medians drops from 0.0117 to 0.0062, roughly halved. The stations whose field z_err was most extreme move the most: 18-018A (the lowest original median, 0.0388) rises to 0.0516, and 18-022U (the highest, 0.0861) falls to 0.0713, both pulled toward a common, narrower band – exactly what “recalibration” should do when the field estimates are noisier than the signal-quality features underneath them.

11.27.8.2. Working directly with site collections#

predict_table() scores an entire site collection at once, and apply() closes the sites-in / sites-out loop the way apply() does – but instead of masking or replacing data outright, it rescales Z.z_err: for every (station, frequency) cell it computes the ratio between the calibrated fractional error and that station’s own original pooled fractional error, then multiplies all four tensor components’ existing z_err by that one ratio, preserving whatever per-component structure the field error already carries (for example a larger diagonal-term error) while shifting the overall level to the calibrated estimate:

>>> cal = UncertaintyCalibrator(hidden=(32, 16))
>>> cal.fit(feats, epochs=150, seed=0, verbose=False)
UncertaintyCalibrator(n_features=4, torch, fitted)
>>> table = cal.predict_table(sites)
>>> table[["station", "freq", "z_err_frac",
...        "z_err_frac_calibrated"]].head(3)
  station     freq  z_err_frac  z_err_frac_calibrated
0 18-001A  10400.0    0.029769                0.044529
1 18-001A   8707.0    0.033041                0.044105
2 18-001A   7289.0    0.032470                0.043674
>>> calibrated_sites = cal.apply(sites, inplace=False)
>>> from pycsamt.emtools._core import _get_z_block, _iter_items
>>> ed_in = next(_iter_items(sites))
>>> ed_out = next(_iter_items(calibrated_sites))
>>> _, z_in, _, ze_in = _get_z_block(ed_in, with_errors=True)
>>> _, z_out, _, ze_out = _get_z_block(ed_out, with_errors=True)
>>> bool(np.allclose(z_in, z_out, equal_nan=True))   # Z.z: untouched
True
>>> ze_in[:3, 0, 1].round(2)
array([59.08, 57.87, 51.19])
>>> ze_out[:3, 0, 1].round(2)
array([88.37, 77.25, 68.85])

Z.z is byte-identical before and after – this method only ever touches Z.z_err – and the first station’s Zxy error grows by roughly 35-50%, consistent with its calibrated median fractional error (0.0461, panel (b) above) sitting above its own original pooled value (0.0405).

Note

floor_frac (default None) enforces a minimum fractional error after calibration, e.g. cal.apply(sites, floor_frac=0.02) for a 2% floor – applied before the rescaling ratio, so it interacts with the calibrated estimate rather than the original one.

11.27.8.3. Parameters and limitations#

hidden (default (64, 32)) sets the regressor backbone width, narrowed to (32, 16) above for a 28-station survey the same way Learned Spectral Denoising narrows its own network for L18PLT. log_target (default True) trains in log10 space – fractional error is right-skewed – and transform() always de-logs back to the original linear scale regardless.

Warning

fit(X, y=None) self-trains only when X is a build_uncertainty_features_table DataFrame carrying its own z_err_frac column. There is no rule-based fallback the way the classifiers in this package synthesise class labels from thresholds – a plain feature array requires an explicit y=. This also means an empirical target (a genuine bootstrap or repeat-measurement spread computed independently, for example from pycsamt.seg.spectra) plugs into the exact same fit(X, y=...) call without any API change – only the automatic y=None default is recalibration-mode.

Note

When neither PyTorch nor TensorFlow is installed, UncertaintyCalibrator falls back to a random-forest regressor (scikit-learn).

Full signatures: UncertaintyCalibrator, build_uncertainty_features_table(), plot_uncertainty_map(), plot_uncertainty_validation(), and plot_uncertainty_summary().

View the complete UncertaintyCalibrator example sourceClick to inspect and copy the complete code
 1def run_uncertainty(sites, save_dir: Path) -> None:
 2    print("\n=== UncertaintyCalibrator ===")
 3    feats = build_uncertainty_features_table(sites)
 4    print(f"  feature rows: {len(feats)}")
 5    print(f"  z_err_frac stats: min={feats['z_err_frac'].min():.4f} "
 6          f"max={feats['z_err_frac'].max():.4f} "
 7          f"median={feats['z_err_frac'].median():.4f}")
 8
 9    # Held-out cells (not stations) for a quantitative RMSE / R^2 check,
10    # the same row-level split UncertaintyCalibrator.fit() itself uses
11    # internally for its own validation loss.
12    rng = np.random.default_rng(0)
13    n = len(feats)
14    idx = rng.permutation(n)
15    n_val = max(1, int(n * 0.2))
16    val_idx, train_idx = idx[:n_val], idx[n_val:]
17    train_df = feats.iloc[train_idx].reset_index(drop=True)
18    val_df = feats.iloc[val_idx].reset_index(drop=True)
19
20    cal_val = UncertaintyCalibrator(hidden=(32, 16))
21    cal_val.fit(train_df, epochs=150, seed=0, verbose=False)
22    y_true = val_df["z_err_frac"].to_numpy()
23    y_pred = cal_val.transform(val_df)
24    rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
25    ss_res = float(np.sum((y_true - y_pred) ** 2))
26    ss_tot = float(np.sum((y_true - y_true.mean()) ** 2)) + 1e-24
27    r2 = 1.0 - ss_res / ss_tot
28    print(f"  held-out ({len(val_df)} cells): RMSE={rmse:.4f} R^2={r2:.3f}")
29
30    baseline = np.full_like(y_true, train_df["z_err_frac"].median())
31    rmse_baseline = float(np.sqrt(np.mean((y_true - baseline) ** 2)))
32    print(f"  RMSE (median baseline): {rmse_baseline:.4f}")
33
34    # Deployed calibrator: fit on the full feature table.
35    cal = UncertaintyCalibrator(hidden=(32, 16))
36    cal.fit(feats, epochs=150, seed=0, verbose=False)
37    print(f"  {cal!r}")
38    hist = cal.history_
39    if hist.get("val_loss"):
40        print(f"  final val_loss: {hist['val_loss'][-1]:.5f}")
41
42    table = cal.predict_table(sites)
43    print(
44        table[
45            ["station", "freq", "z_err_frac", "z_err_frac_calibrated"]
46        ].head(5).to_string(index=False)
47    )
48
49    agg = table.groupby("station")[
50        ["z_err_frac", "z_err_frac_calibrated"]
51    ].median()
52    print("  median fractional error per station (head):")
53    print(agg.head(5).to_string())
54
55    fig = plot_uncertainty_summary(
56        table, y_true, y_pred, history=cal,
57        suptitle="UncertaintyCalibrator -- L18PLT",
58    )
59    _save(fig, save_dir, "uncertainty_summary.png")
60
61    # apply(): Z.z untouched, Z.z_err rescaled.
62    calibrated_sites = cal.apply(sites, inplace=False)
63    from pycsamt.emtools._core import _get_z_block, _iter_items
64
65    ed_in = next(_iter_items(sites))
66    ed_out = next(_iter_items(calibrated_sites))
67    _, z_in, _fr_in, ze_in = _get_z_block(ed_in, with_errors=True)
68    _, z_out, _fr_out, ze_out = _get_z_block(ed_out, with_errors=True)
69    same_z = bool(np.allclose(z_in, z_out, equal_nan=True))
70    changed_err = not bool(np.allclose(ze_in, ze_out, equal_nan=True))
71    print(f"  apply(): Z.z unchanged={same_z}, Z.z_err changed={changed_err}")
72    print(f"  z_err before[:3, 0, 1]: {ze_in[:3, 0, 1]}")
73    print(f"  z_err after  [:3, 0, 1]: {ze_out[:3, 0, 1]}")