11.27.7. Learned Gap Filling#

Learned Spectral Denoising assumes every (station, frequency) cell has some value and improves its quality. Real surveys also have cells with no value at all – a frequency dropped mid-acquisition, a component that never converged, a band skipped for poor coherence – and nothing else in pyCSAMT fills those gaps with anything better than carrying NaN straight through to inversion. EMImputer targets that gap directly: reconstructing the missing cells from the observed ones using spectral smoothness – the same station, nearby frequencies – a matrix-completion problem, not a denoising one.

The recipe is the masked-reconstruction family, the same idea behind BERT-style masked-language modelling or image inpainting:

(1)#\[\hat{\mathbf{x}} = f_\theta(\mathbf{x} \odot \mathbf{m},\ \mathbf{m}), \qquad \mathcal{L} = \frac{\big\| (\hat{\mathbf{x}} - \mathbf{x}) \odot \mathbf{m}_\text{syn} \big\|_2^2}{\sum \mathbf{m}_\text{syn}}\]

where \(\mathbf{m} \in \{0, 1\}\) is the mask fed to the network (1 = “this cell is observed”) and \(\mathbf{m}_\text{syn}\) marks the subset of genuinely observed cells synthetically hidden on that training step – exactly where the loss is evaluated. On every step, fit() hides a random mask_frac fraction of the truly-observed cells (any real NaN gaps already in the training data are always hidden and never used as a target, since their true value is unknown), mirroring the way EMDenoiser adds synthetic noise. Training data therefore only needs to be mostly complete, not to come with real missing-data examples of known ground truth.

The network is the same 1-D convolutional encoder-decoder family as EMDenoiser, with one structural difference: the encoder takes 2 * n_components input channels – the zeroed-where-hidden values, concatenated with the observed-cell mask along the channel axis – so it can distinguish “zero because hidden” from “zero because that is genuinely the value”, while the decoder still only reconstructs n_components channels; the mask itself is never reconstructed.

11.27.7.1. A survey with real gaps#

L18PLT, used throughout the rest of this section, has no genuinely missing cells to fill – every earlier page here either works with complete data or synthesises noise on top of it. This page instead uses data/MT/broken-hill: 21 real “ultra-wide-band” magnetotelluric soundings recorded by the University of Sydney around the Broken Hill Pb-Zn-Ag deposit, New South Wales (AlQahtani et al., 2026, open-access CC-BY). Several of its stations really do have missing frequency rows – dropped estimates, most likely from reduced coherence at the long-period end of a station’s recording, where a wideband instrument has proportionally less time to average down noise.

Data and model citation

AlQahtani, Y., Ozaydin, S., Chatzaras, V., Rey, P. F., & Passos, T. (2026). Why does the Broken Hill deposit sit in resistive crust? Magnetotelluric evidence for metamorphic decoupling of a world-class mineral system. Journal of Geophysical Research: Solid Earth, 131, e2026JB035666. https://doi.org/10.1029/2026JB035666

AlQahtani, Y., Ozaydin, S., Chatzaras, V., Rey, P. F., & Passos, T. (2026). Open data for the article “Why does the Broken Hill deposit sit in resistive crust?”. Zenodo. https://doi.org/10.5281/zenodo.21272106

See Broken Hill: MT Field Data To A 3-D Resistivity Model for the full survey carried through to a published 3-D inversion model, and that tutorial’s own citation dropdown for the complete provenance.

>>> from pycsamt.ai.processing import EMImputer, prepare_z_features
>>> from pycsamt.emtools import ensure_sites
>>> sites = ensure_sites("data/MT/broken-hill/edis", recursive=True)
>>> X = prepare_z_features(sites, n_components=4)
>>> X.shape
(21, 4, 96)
>>> import numpy as np
>>> missing = np.isnan(X).any(axis=1)   # (n_sites, n_freqs)
>>> int(missing.sum())
43

prepare_z_features resamples every station onto one common frequency grid (freq_ref, the first site’s own grid by default) – 43 (station, frequency) rows come out with at least one NaN component after that resampling, spread across 5 of the 21 stations:

>>> per_site = missing.sum(axis=1)
>>> from pycsamt.emtools._core import _iter_items, _name
>>> labels = [_name(ed, i) for i, ed in enumerate(_iter_items(sites))]
>>> {labels[i]: int(per_site[i]) for i in range(len(labels))
...  if per_site[i] > 0}
{'BH_11_imp': 18, 'BH_19_imp': 10, 'BH_1_imp': 3, 'BH_4_imp': 9, 'BH_5_imp': 3}
../../_images/imputer_summary.png

plot_imputer_summary() – (a) the station x frequency missing-data map (note the gaps cluster almost entirely at long period, consistent with the coherence explanation above); (b) two of the five gapped stations, filled curve in blue with reconstructed cells marked in red; (c) the training curve.#

11.27.7.2. Quantitative validation on held-out cells#

A genuinely missing cell has no known value to check a reconstruction against, so the standard masked-reconstruction validation protocol does the opposite of what a real gap is: it hides a known fraction of the already observed cells, reconstructs them, and compares the reconstruction to the real, held-back values.

>>> rng = np.random.default_rng(0)
>>> observed = np.isfinite(X)
>>> holdout = (rng.random(X.shape) < 0.12) & observed
>>> X_holdout = X.copy()
>>> X_holdout[holdout] = np.nan
>>> int(holdout.sum())
963
>>> imp = EMImputer(channels=(32, 64, 32))
>>> imp.fit(X_holdout, mask_frac=0.15, epochs=150, seed=0, verbose=False)
EMImputer(n_freqs=96, n_components=4, fitted)
>>> X_recon = imp.transform(X_holdout)
>>> y_true, y_pred = X[holdout], X_recon[holdout]

Warning

As with every network-based estimator in this package, seed fixes the held-out split and the synthetic training mask 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.

A single pooled RMSE across all four feature channels would repeat the mistake Learned Spectral Denoising warns about: log-amplitude and degree-valued phase channels sit on completely different scales, and whichever one has the larger numeric range would dominate. Broken down by channel against a channel-mean “predict nothing” baseline, every channel improves substantially:

Channel

n cells

RMSE, EMImputer

RMSE, baseline

\(\log_{10}|Z_{xy}|\)

234

0.179

0.978

\(\phi_{xy}\) (deg)

215

8.098

16.480

\(\log_{10}|Z_{yx}|\)

279

0.182

1.026

\(\phi_{yx}\) (deg)

235

6.081

17.983

../../_images/imputer_validation.png

plot_imputer_validation(), one panel per feature component – true value on the x-axis, reconstructed value on the y-axis, dashed line at \(y = x\). Both log-amplitude channels track the diagonal closely (\(R^2\) 0.966 and 0.968); the phase channels are noisier but still clearly informative (\(R^2\) 0.755 and 0.885), consistent with phase generally being the harder quantity to reconstruct from neighbouring frequencies alone.#

../../_images/imputer_reconstruction.png

plot_imputer_reconstruction() at full resolution for the four most-gapped stations. The reconstructed points (red) sit smoothly on the continuation of each curve’s real, observed trend rather than jumping off it – direct visual evidence that the network is extrapolating the station’s own spectral shape, not producing arbitrary values.#

11.27.7.3. Sites in, filled sites out#

apply() closes the same sites-in / sites-out loop as Learned Spectral Denoising’s apply(): pass a site collection, get one back, with an inplace flag controlling whether the input is mutated. The version fitted here trains on the full array – the real gaps stay NaN and excluded from the loss, none of the synthetic held-out cells from the validation above are involved:

>>> imp = EMImputer(channels=(32, 64, 32))
>>> imp.fit(X, mask_frac=0.15, epochs=150, seed=0, verbose=False)
EMImputer(n_freqs=96, n_components=4, fitted)
>>> filled_sites = imp.apply(sites, inplace=False)

Checking station BH_11_imp directly confirms both halves of that contract: every cell that was already observed comes back unchanged, and every genuinely missing row now has a reconstructed value.

>>> from pycsamt.emtools._core import _get_z_block
>>> gi = labels.index("BH_11_imp")
>>> ed_in = list(_iter_items(sites))[gi]
>>> ed_out = list(_iter_items(filled_sites))[gi]
>>> _, z_in, _ = _get_z_block(ed_in, with_errors=False)[:3]
>>> _, z_out, _ = _get_z_block(ed_out, with_errors=False)[:3]
>>> obs = ~np.isnan(z_in).any(axis=(1, 2))
>>> bool(np.allclose(z_in[obs], z_out[obs], equal_nan=True))
True
>>> was_missing = np.isnan(z_in).any(axis=(1, 2))
>>> int((was_missing & ~np.isnan(z_out[:, 0, 1])
...      & ~np.isnan(z_out[:, 1, 0])).sum())
20

Every observed cell in BH_11_imp is untouched, and all 20 of its missing rows now have \(Z_{xy}\) and \(Z_{yx}\) filled.

Warning

With the default n_components=4, only \(Z_{xy}\) and \(Z_{yx}\) are modelled – a real missing measurement usually means the whole frequency row (all four components) is absent, and the diagonal \(Z_{xx}, Z_{yy}\) at those rows is left exactly as it was, NaN if it was NaN, the same “not modelled -> untouched” contract apply() uses. Fit and apply with n_components=8 to fill every component.

11.27.7.4. Parameters and limitations#

mask_frac (default 0.15) controls how much of the observed data is hidden on every training step – too low and the network sees little missing-data signal to learn from; too high and too little context remains around a hidden cell for it to reconstruct anything. channels sets the encoder/decoder width at each stage, narrowed to (32, 64, 32) above for a 21-station survey the same way Learned Spectral Denoising narrows it for L18PLT. n_components (4 or 8) trades model complexity against diagonal-component coverage, as shown above.

Note

When neither PyTorch nor TensorFlow is installed, EMImputer falls back to per-sample, per-channel linear interpolation along the frequency axis – no cross-station information, unlike the network path, and a channel with no observed value anywhere in the training batch falls back further still, to that channel’s training mean.

Full signatures: EMImputer, prepare_z_features(), plot_imputer_gaps(), plot_imputer_validation(), plot_imputer_reconstruction(), and plot_imputer_summary().

View the complete EMImputer example sourceClick to inspect and copy the complete code
  1def run_imputer(sites, save_dir: Path) -> None:
  2    print("\n=== EMImputer ===")
  3    labels = station_names(sites)
  4    X = prepare_z_features(sites, n_components=4)
  5    n_sites, _n_comp, n_freq = X.shape
  6    print(f"  X shape: {X.shape}")
  7
  8    from pycsamt.emtools._core import _get_z_block, _iter_items
  9
 10    freq = None
 11    for ed in _iter_items(sites):
 12        result = _get_z_block(ed, with_errors=False)
 13        freq = result[2] if len(result) >= 3 else None
 14        if freq is not None:
 15            break
 16
 17    missing_mask = ~np.isfinite(X)
 18    row_missing = missing_mask.any(axis=1)  # (n_sites, n_freq)
 19    per_site = row_missing.sum(axis=1)
 20    gapped = [
 21        (labels[i], int(per_site[i]))
 22        for i in range(n_sites)
 23        if per_site[i] > 0
 24    ]
 25    print(f"  genuinely missing rows: {int(row_missing.sum())} "
 26          f"across {len(gapped)} / {n_sites} stations")
 27    for name, n in gapped:
 28        print(f"    {name}: {n} missing rows (of {n_freq})")
 29
 30    gap_table = pd.DataFrame(
 31        {
 32            "station": np.repeat(labels, n_freq),
 33            "freq": np.tile(freq, n_sites),
 34            "missing": row_missing.astype(int).ravel(),
 35        }
 36    )
 37
 38    # Quantitative validation: real gaps have no known ground truth, so
 39    # hold out a random 12% of the *genuinely observed* cells instead
 40    # (seeded), fit with them additionally hidden, then compare the
 41    # reconstruction against the real, known values at those cells.
 42    rng = np.random.default_rng(0)
 43    observed = np.isfinite(X)
 44    holdout = (rng.random(X.shape) < 0.12) & observed
 45    X_holdout = X.copy()
 46    X_holdout[holdout] = np.nan
 47    print(f"  held-out cells for validation: {int(holdout.sum())}")
 48
 49    imp_val = EMImputer(channels=(32, 64, 32))
 50    imp_val.fit(
 51        X_holdout, mask_frac=0.15, epochs=150, seed=0, verbose=False
 52    )
 53    X_recon_holdout = imp_val.transform(X_holdout)
 54    y_true = X[holdout]
 55    y_pred = X_recon_holdout[holdout]
 56    rmse_model = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
 57
 58    chan_mean = np.nanmean(X_holdout, axis=(0, 2), keepdims=True)
 59    y_pred_naive = np.broadcast_to(chan_mean, X.shape)[holdout]
 60    rmse_naive = float(np.sqrt(np.mean((y_true - y_pred_naive) ** 2)))
 61    print(f"  RMSE (EMImputer), pooled:     {rmse_model:.4f}")
 62    print(f"  RMSE (channel-mean baseline): {rmse_naive:.4f}")
 63    print("  (pooled RMSE mixes log-amplitude and degree-valued phase "
 64          "channels -- see per-channel breakdown below)")
 65
 66    comp_ids = np.broadcast_to(
 67        np.arange(X.shape[1])[None, :, None], X.shape
 68    )[holdout]
 69    for ci, name in enumerate(
 70        ["log|Zxy|", "phase_xy", "log|Zyx|", "phase_yx"]
 71    ):
 72        cm = comp_ids == ci
 73        rm = float(np.sqrt(np.mean((y_true[cm] - y_pred[cm]) ** 2)))
 74        rb = float(
 75            np.sqrt(np.mean((y_true[cm] - y_pred_naive[cm]) ** 2))
 76        )
 77        print(f"    {name:>9s} (n={int(cm.sum())}): "
 78              f"RMSE model={rm:.3f}  baseline={rb:.3f}")
 79
 80    # The deployed imputer: fit on the full array (only the genuine
 81    # gaps are NaN -- none of the synthetic holdout above), then fill
 82    # those real gaps via apply().
 83    imp = EMImputer(channels=(32, 64, 32))
 84    imp.fit(X, mask_frac=0.15, epochs=150, seed=0, verbose=False)
 85    print(f"  {imp!r}")
 86    hist = imp.history_
 87    if hist.get("val_loss"):
 88        print(f"  final val_loss: {hist['val_loss'][-1]:.5f}")
 89
 90    X_filled = imp.transform(X)
 91    filled_sites = imp.apply(sites, inplace=False)
 92
 93    # Verify: observed cells byte-identical, gapped station now filled.
 94    gap_name = gapped[0][0] if gapped else labels[0]
 95    gi = labels.index(gap_name)
 96    for i, (ed_in, ed_out) in enumerate(
 97        zip(_iter_items(sites), _iter_items(filled_sites))
 98    ):
 99        if i != gi:
100            continue
101        _, z_in, _fr_in = _get_z_block(ed_in, with_errors=False)[:3]
102        _, z_out, _fr_out = _get_z_block(ed_out, with_errors=False)[:3]
103        obs = ~np.isnan(z_in).any(axis=(1, 2))
104        same_observed = bool(
105            np.allclose(z_in[obs], z_out[obs], equal_nan=True)
106        )
107        was_missing = np.isnan(z_in).any(axis=(1, 2))
108        offdiag_filled = int(
109            (
110                was_missing
111                & ~np.isnan(z_out[:, 0, 1])
112                & ~np.isnan(z_out[:, 1, 0])
113            ).sum()
114        )
115        diag_still_nan = int(
116            (
117                was_missing
118                & (np.isnan(z_out[:, 0, 0]) | np.isnan(z_out[:, 1, 1]))
119            ).sum()
120        )
121        print(f"  {gap_name}: observed rows unchanged = {same_observed}; "
122              f"of {int(was_missing.sum())} missing rows, "
123              f"{offdiag_filled} now have Zxy/Zyx filled, "
124              f"{diag_still_nan} still have Zxx/Zyy = NaN "
125              f"(n_components=4 -> diagonal not modelled)")
126        break
127
128    fig = plot_imputer_summary(
129        gap_table, freq, X, X_filled,
130        history=imp, station_labels=labels,
131        n_show=2,
132        suptitle="EMImputer -- Broken Hill MT survey",
133    )
134    _save(fig, save_dir, "imputer_summary.png")
135
136    show_idx = [labels.index(n) for n, _ in gapped[:4]] or [0]
137    fig = plot_imputer_reconstruction(
138        freq, X, X_filled, station_labels=labels, sites=show_idx,
139        suptitle="Reconstructed cells -- Broken Hill stations with real gaps",
140    )
141    _save(fig, save_dir, "imputer_reconstruction.png")
142
143    fig = plot_imputer_validation(
144        y_true, y_pred, component_ids=comp_ids,
145        component_labels=["log|Zxy|", "phase_xy", "log|Zyx|", "phase_yx"],
146        suptitle="Held-out cell reconstruction, by component",
147    )
148    _save(fig, save_dir, "imputer_validation.png")