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:
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}
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 |
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.#
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")