18.14. AI Inversion From Corrected EDIs#

This tutorial starts where the processing tutorials end: you already have a folder of corrected EDI files and you want to use AI inversion instead of, or alongside, a classical Occam2D, ModEM, or MARE2DEM run. It runs the complete, real pipeline end to end on one survey — loading, auditing, a geological prior, a 2-D Maxwell training mesh, a 2-D AI inversion, a 3-D AI inversion, real station topography, and an out-of-distribution check — and reports what actually came out, including the parts that did not work well.

The survey is K2: Line 2 of a real Chinese CSAMT exploration line (station prefix Z2HX), 86 corrected EDI files, a magnetotelluric acquisition with a cross-sounding array at 20 m station spacing per its own field log, 29 frequencies per station spanning 15.8 Hz–10 kHz, and real station coordinates and elevation:

k2_corrected/

Static shift, near-field, and coordinate corrections of the kind EM Tools Guide’s diagnostics pages cover are assumed already applied. K2 is intentionally not bundled with pyCSAMT — replace the path with your own exported corrected EDI folder, for example results/L18PLT_first_qc/processed, to repeat every step here on your own survey.

18.14.1. What You Will Learn#

After this tutorial you should be able to:

  • audit a corrected survey’s dimensionality, geoelectric strike consistency, frequency-grid, and station-coordinate quality before choosing an inversion path, not after;

  • ground a geological prior’s resistivity range in the survey’s own apparent resistivity instead of guessing one;

  • build a real 2-D Maxwell training mesh and dataset sized to the survey’s actual station spacing and extent;

  • run Inv2DAgent’s physics="mt2d" 2-D AI inversion and read its automatic held-out recovery check;

  • run Inv3DAgent’s graph-convolutional 3-D AI inversion from real station coordinates, with Monte Carlo dropout uncertainty;

  • drape a prediction below real station topography, and know that this is a display correction, not new forward physics;

  • catch a confidently wrong prediction with an out-of-distribution screen before a plausible-looking figure is mistaken for a validated one.

18.14.2. Reproducibility before the private case study#

K2 is not distributed with pyCSAMT, so the figure generator separates portable and survey-specific work. Geological composition, mesh construction, the Maxwell training pair, and the following learning audit run without K2. The audit uses eight stations from bundled data/AMT/WILLY_data/L18PLT and the same Inv2DAgent API. It is deliberately small—24 profiles and eight epochs—so it verifies installation and exposes the result contract; it is not a scientifically accepted inversion.

Executed training and validation losses and their difference for a small bundled WILLY Inv2DAgent run

Executed, portable learning audit on bundled corrected EDIs.#

Validation loss below training loss in this short run is possible because the training objective sees augmentation and mini-batch variability while a tiny validation split can be easier by chance. It is not evidence that the model generalizes unusually well. The important observations are that both curves are retained, neither has stabilized convincingly in eight epochs, and the field RMS is reported independently. A production decision requires repeated seeds and held-out recovery, not a preferred curve shape from one smoke run.

View and copy the bundled smoke-run codeClick to inspect and copy the complete code
 1def make_training_convergence_smoke() -> None:
 2    """Run a small bundled-data fit so tutorial readers can audit learning dynamics."""
 3    from pycsamt.agents import Inv2DAgent
 4    from pycsamt.emtools._core import ensure_sites
 5
 6    sites = ensure_sites(ROOT / "data" / "AMT" / "WILLY_data" / "L18PLT",
 7                         recursive=True, verbose=0).ordered()
 8    names = [site.name for site in sites][:8]
 9    subset = sites.select(names=names).ordered()
10    frequencies = np.logspace(0, 3, 10)
11    result = Inv2DAgent(
12        physics="mt1d", n_depth=12, n_stations_per_profile=8,
13        n_train_profiles=24, epochs=8, depth_max=1200,
14        api_key=None,
15    ).execute({"sites": subset, "freqs": frequencies})
16    if result.status != "success":
17        raise RuntimeError(result.error)
18    history = result.data["inverter"]._history
19    train = np.asarray(history["train_loss"])
20    valid = np.asarray(history["val_loss"])
21    epochs = np.arange(1, len(train) + 1)
22    fig, axes = plt.subplots(1, 2, figsize=(10.8, 4.2))
23    axes[0].plot(epochs, train, "o-", label="training", color="#2563eb")
24    axes[0].plot(epochs, valid, "s-", label="validation", color="#f15a29")
25    axes[0].set(xlabel="Epoch", ylabel="Loss", title="Learning curves are evidence, not decoration")
26    axes[0].grid(alpha=0.25)
27    axes[0].legend(frameon=False)
28    gap = valid - train
29    axes[1].bar(epochs, gap, color=np.where(gap > 0, "#dc2626", "#16a34a"))
30    axes[1].axhline(0, color="#111827", lw=1)
31    axes[1].set(xlabel="Epoch", ylabel="Validation - training loss",
32                title=f"Generalization gap; field RMS={result.data['rms_global']:.2f}")
33    axes[1].grid(alpha=0.25, axis="y")
34    fig.suptitle("Bundled WILLY smoke run (24 profiles, 8 epochs): dynamics, not acceptance", fontsize=12)
35    fig.tight_layout()
36    _save(fig, "training_convergence_smoke.png")

Run the complete generator with

python docs/scripts/generate_tutorial_ai_inversion.py

When k2_corrected is absent, it writes the portable figures and explicitly skips only the K2 audit and inversion figures. Replace K2_DIR with your corrected EDI directory to execute the survey-specific path.

18.14.3. Load and Audit the Survey#

Loading a corrected EDI folder is one call:

>>> from pycsamt.emtools._core import ensure_sites
>>> sites = ensure_sites(
...     "k2_corrected", recursive=False, verbose=0
... ).ordered()
>>> print("stations:", len(sites))
stations: 86
>>> print("ordering applied:", sites.ordering["applied"])
ordering applied: chainage
>>> print("profile linearity:", round(sites.ordering["linearity"], 5))
profile linearity: 0.99998
>>> print("profile span (m):", round(sites.ordering["span_m"], 1))
profile span (m): 1635.7

ordered() confirms K2 is a clean, nearly perfectly linear 1636 m profile before anything else runs — but a linear profile is not the same as a survey ready for a fixed-axis 2-D or per-station AI inversion. audit_survey() checks that directly:

>>> from pycsamt.ai.domain_gap import audit_survey
>>> report = audit_survey(sites, verbose=0)
>>> print("frequency grid matched:", report.frequency_grid.matched)
frequency grid matched: False
>>> print(
...     "stations with a different grid:",
...     len(report.frequency_grid.mismatched_stations),
... )
stations with a different grid: 53
>>> print(
...     "station spacing (m):",
...     {k: round(v, 2) for k, v in report.station_spacing_m.items()},
... )
station spacing (m): {'min': 0.0, 'median': 19.94, 'max': 41.32}
>>> dim = report.dimensionality
>>> print("dimensionality samples:", dim.n_samples)
dimensionality samples: 2208
>>> print(
...     "fraction 1-D / 2-D / 3-D:",
...     round(dim.frac_1d, 4), round(dim.frac_2d, 4), round(dim.frac_3d, 4),
... )
fraction 1-D / 2-D / 3-D: 0.0082 0.0942 0.8976
>>> print(
...     "strike consensus / IQR (deg):",
...     round(dim.strike_consensus_deg, 2),
...     round(dim.strike_consensus_iqr_deg, 2),
... )
strike consensus / IQR (deg): -9.36 84.39
>>> print(
...     "stations recommending 3-D review:",
...     len(dim.stations_recommending_3d_review),
... )
stations recommending 3-D review: 86
>>> print("static shift log10 sigma:", round(report.static_shift_log10_sigma, 4))
static shift log10 sigma: 0.3943
>>> print(
...     "distortion twist sigma (deg):",
...     round(report.distortion_twist_deg_sigma, 2),
... )
distortion twist sigma (deg): 35.03

Four real findings come out of this before a single inversion has run:

  • The median 19.94 m station spacing matches the field log’s declared 20 m point spacing almost exactly — a genuine, independent confirmation that the corrected coordinates are trustworthy.

  • The minimum spacing of 0.0 m is not trustworthy: at least one pair of stations shares an identical coordinate. K2 turns out to have four — Z2HX042/Z2HX043, Z2HX068/Z2HX069, Z2HX074/Z2HX075, and Z2HX086/Z2HX087 — most likely a repeat occupation recorded under two station names.

  • 53 of 86 stations do not share the reference station’s exact frequency set; FrequencyGridReport’s per-station counts range from 8 to 29 across the line, so a workflow that assumes one common frequency axis (as the sections below do) is already resampling, not just reading, most of this survey.

  • Nearly 90% of K2’s station-period samples classify as dimensionality 3-D, barely 1% as 1-D, and every one of the 86 stations is flagged for a 3-D review by pre2d_inversion_assessment(). The geoelectric strike estimate is not a clean, sharp azimuth either: a median of -9.36° sounds precise until its 84° interquartile spread shows individual stations disagreeing with each other almost as much as they possibly could.

K2 corrected EDI elevation, frequency counts, dimensionality fractions, and station-spacing distribution

Four pre-inversion checks computed from the corrected EDI collection.#

The panels make the decision problem visible. Elevation is smooth enough to define a credible terrain profile, whereas frequency support changes sharply near stations 50–60 and must be resampled with masks retained. The dominant 3-D classification is not a small minority of suspect periods; it controls the dimensionality decision. Finally, the tight spacing peak verifies the nominal acquisition interval after duplicate coordinates have been removed. No model architecture can repair these issues after training: they determine the data contract and admissible physics before training begins.

View and copy the survey-audit figure codeClick to inspect and copy the complete code
 1def make_survey_audit() -> None:
 2    """Visualize the K2 geometry, frequency coverage, and dimensionality gate."""
 3    from pycsamt.ai.domain_gap import audit_survey
 4
 5    sites = _k2_clean_sites()
 6    report = audit_survey(sites, verbose=0)
 7    names = [site.name for site in sites]
 8    elevations = np.array([site.coords[2] for site in sites], dtype=float)
 9    counts = np.array([len(site.freq) for site in sites], dtype=int)
10    dim = report.dimensionality
11    fractions = np.array([dim.frac_1d, dim.frac_2d, dim.frac_3d])
12    lat = np.array([site.coords[0] for site in sites], dtype=float)
13    lon = np.array([site.coords[1] for site in sites], dtype=float)
14    lat0 = np.radians(np.nanmean(lat))
15    dx = np.diff(lon) * 111_320.0 * np.cos(lat0)
16    dy = np.diff(lat) * 110_574.0
17    spacing = np.sqrt(dx**2 + dy**2)
18
19    fig, axes = plt.subplots(2, 2, figsize=(11.5, 7.4))
20    index = np.arange(len(sites))
21    axes[0, 0].plot(index, elevations, color="#2563eb", lw=1.8)
22    axes[0, 0].fill_between(index, elevations.min(), elevations,
23                            color="#bfdbfe", alpha=0.7)
24    axes[0, 0].set(xlabel="Ordered station index", ylabel="Elevation (m)",
25                   title="Terrain carried by corrected EDI headers")
26    axes[0, 0].grid(alpha=0.2)
27    axes[0, 1].bar(index, counts, color=np.where(counts == counts.max(), "#16a34a", "#f59e0b"))
28    axes[0, 1].set(xlabel="Ordered station index", ylabel="Available frequencies",
29                   title="Frequency support is not uniform")
30    axes[0, 1].grid(alpha=0.2, axis="y")
31    axes[1, 0].bar(["1-D", "2-D", "3-D"], fractions,
32                   color=["#16a34a", "#2563eb", "#dc2626"])
33    axes[1, 0].set(ylim=(0, 1), ylabel="Fraction of station-period samples",
34                   title="Tensor dimensionality controls model choice")
35    for i, value in enumerate(fractions):
36        axes[1, 0].text(i, value + 0.025, f"{value:.1%}", ha="center")
37    axes[1, 0].grid(alpha=0.2, axis="y")
38    axes[1, 1].hist(spacing[np.isfinite(spacing)], bins=18, color="#64748b",
39                    edgecolor="white")
40    axes[1, 1].axvline(report.station_spacing_m["median"], color="#f15a29",
41                       ls="--", label=f"median={report.station_spacing_m['median']:.1f} m")
42    axes[1, 1].set(xlabel="Adjacent spacing (m)", ylabel="Count",
43                   title="Geometry audit precedes meshing")
44    axes[1, 1].legend(frameon=False)
45    fig.suptitle("Corrected does not mean inversion-ready: audit K2 before training", fontsize=13)
46    fig.tight_layout()
47    _save(fig, "survey_audit.png")

None of the sections below rotate the tensor into that (barely consistent) strike frame — zxy and zyx are used exactly as the EDI files store them, which is also what Inv2DAgent and Inv3DAgent do internally. Strike rotation is what would normally separate a clean TE response from a clean TM response at a station whose local strike differs from the survey’s nominal direction; skip it, and a component literally named zxy is under no obligation to behave like the “TE-like” curve either agent’s training data was built to expect. That single sentence is the mechanical explanation for most of what the rest of this tutorial finds. The duplicate stations are dropped before anything downstream needs a strictly increasing chainage:

>>> lat = [s.coords[0] for s in sites]
>>> lon = [s.coords[1] for s in sites]
>>> names = [s.name for s in sites]
>>> keep = [names[0]]
>>> last = (lat[0], lon[0])
>>> for i in range(1, len(names)):
...     if (lat[i], lon[i]) == last:
...         continue
...     keep.append(names[i])
...     last = (lat[i], lon[i])
...
>>> clean = sites.select(names=keep).ordered()
>>> print("clean stations:", len(clean))
clean stations: 82

18.14.4. Ground a Geological Prior in the Survey#

Correlated geological priors and 2-D Maxwell training-data generation need a log_resistivity_mean/log_resistivity_std pair for the synthetic training realizations. Guessing one is not necessary when the survey itself already reports an apparent resistivity:

>>> import numpy as np
>>> rho_xy = np.concatenate([s.rho[:, 0, 1] for s in sites])
>>> rho_yx = np.concatenate([s.rho[:, 1, 0] for s in sites])
>>> rho_all = np.concatenate([rho_xy, rho_yx])
>>> rho_all = rho_all[np.isfinite(rho_all) & (rho_all > 0)]
>>> print(
...     "median apparent resistivity (ohm.m):",
...     round(float(np.median(rho_all)), 1),
... )
median apparent resistivity (ohm.m): 117.3
>>> print(
...     "log10 mean / std:",
...     round(float(np.log10(rho_all).mean()), 4),
...     round(float(np.log10(rho_all).std()), 4),
... )
log10 mean / std: 1.9456 1.2342

The survey’s own median apparent resistivity of 117 Ω·m puts log_resistivity_mean=2.0 (100 Ω·m) within a few percent of the data, rather than an arbitrary round number. Its raw log-spread of 1.23 decades is not copied directly into log_resistivity_std, though: apparent resistivity smears every depth and every local distortion into one frequency-indexed curve, so its spread always overstates the true resistivity variability a geological prior should target. log_resistivity_std=0.5 — roughly half the raw spread — is the conservative choice used for both inversions below.

That statistical range still does not define geology. A robust prior must state which units occupy which depths, how interfaces vary laterally, and how terrain divides air from earth. The complete figure code below constructs three electrical units, correlated interface relief, an interpolated TopographicSurface, and a padded solver mesh.

Layered geological prior, topographic earth mask, and padded Maxwell solver mesh

The same model at three successive contracts: geological target, terrain mask, and physics-facing padded mesh.#

The left panel is the supervised target, not a prediction: its white curves are stochastic interfaces and its within-unit variation follows declared correlation lengths. In the middle panel, station elevations become a continuous surface and a Boolean air/earth mask; interpolation is therefore a modelling choice with provenance, not merely a plotting option. The right panel adds five air, five bottom-padding, and five padding cells on each side. The dark air is assigned a small numerical conductivity rather than rock resistivity.

The mesh is intentionally diagnostic. Its 930 cells resolve the smallest skin depth with only 0.298 core cells, while MeshDesign requests four. Therefore solver.quality.acceptable is false and its warning must not be ignored. The dataset builder used for the figure below refines this geometry to 39,776 cells; the separately seeded sample in the executable block reaches 58,052. The padded count can change with the realization’s resistivity extrema because they change the skin-depth requirement. This is why geological-grid resolution and Maxwell-solver resolution are related but not interchangeable.

View and copy the geology, topography, and mesh codeClick to inspect and copy the complete code
 1def make_geology_topography_mesh() -> None:
 2    """Build the tutorial prior, terrain surface, and padded solver mesh."""
 3    from pycsamt.ai.geology import (
 4        ElectricalLayer,
 5        GaussianCorrelation,
 6        GeologyGrid,
 7        generate_layered_geology,
 8        interpolate_topography,
 9    )
10    from pycsamt.forward.maxwell.mesh import MeshDesign, build_solver_mesh
11
12    grid = GeologyGrid.regular_2d(nx=21, nz=20, dx_m=80.8, dz_m=100.0)
13    layers = (
14        ElectricalLayer("conductive cover", 35.0, log10_std=0.12,
15                        heterogeneity=GaussianCorrelation(350, 100)),
16        ElectricalLayer("weathered host", 140.0, log10_std=0.16,
17                        heterogeneity=GaussianCorrelation(500, 160)),
18        ElectricalLayer("resistive basement", 1200.0, log10_std=0.10,
19                        heterogeneity=GaussianCorrelation(700, 220)),
20    )
21    geology = generate_layered_geology(
22        grid, layers, [380.0, 1050.0], seed=17,
23        interface_relief_std_m=[45.0, 90.0],
24        interface_correlation=GaussianCorrelation(550, 150),
25        minimum_thickness_m=120.0,
26    )
27    control_x = np.linspace(grid.x_m[0], grid.x_m[-1], 9)
28    elevation = 285 + 42 * np.sin(control_x / 310) + 18 * np.cos(control_x / 145)
29    topography = interpolate_topography(
30        grid, control_x, elevation, source="corrected EDI elevations",
31        interpolation_method="cubic",
32    )
33    solver = build_solver_mesh(
34        grid, resistivity_ohm_m=geology.resistivity_ohm_m,
35        frequencies_hz=[15.8, 10_000.0], topography=topography,
36        design=MeshDesign(horizontal_padding_cells=5, bottom_padding_cells=5,
37                          air_layers=5),
38    )
39
40    fig, axes = plt.subplots(1, 3, figsize=(13.2, 4.7))
41    extent = (0, grid.x_m[-1] / 1000, grid.z_m[-1] / 1000, 0)
42    image = axes[0].imshow(np.log10(geology.resistivity_ohm_m), extent=extent,
43                           aspect="auto", cmap="viridis_r", vmin=1, vmax=3.3)
44    for interface in geology.interface_depth_m:
45        axes[0].plot(grid.x_m / 1000, interface / 1000, "w-", lw=1)
46    axes[0].set(title="Geological target before meshing", xlabel="Distance (km)",
47                ylabel="Depth below reference (km)")
48    fig.colorbar(image, ax=axes[0], label=r"$\log_{10}\rho$ [$\Omega\cdot$m]")
49
50    topo_image = axes[1].imshow(topography.earth_mask(), extent=extent,
51                                aspect="auto", cmap="Greys", vmin=0, vmax=1)
52    axes[1].plot(grid.x_m / 1000, topography.surface_depth_m / 1000,
53                 color="#f15a29", lw=2, label="interpolated terrain")
54    axes[1].scatter(control_x / 1000,
55                    topography.surface_depth_m[np.searchsorted(grid.x_m, control_x)] / 1000,
56                    marker="v", color="#2563eb", s=24, label="EDI elevations")
57    axes[1].set(title=f"Terrain mask: relief={topography.relief_m:.0f} m",
58                xlabel="Distance (km)", ylabel="Depth below reference (km)")
59    axes[1].legend(facecolor="white", framealpha=0.9, fontsize=8,
60                   loc="lower left")
61    fig.colorbar(topo_image, ax=axes[1], ticks=[0, 1], label="air (0) / earth (1)")
62
63    x_edges = solver.mesh.x_edges_m / 1000
64    z_edges = solver.mesh.z_edges_m / 1000
65    mesh_values = np.log10(1.0 / solver.conductivity_s_m)
66    mesh_image = axes[2].pcolormesh(x_edges, z_edges, mesh_values,
67                                    shading="flat", cmap="viridis_r", vmin=1, vmax=8)
68    axes[2].set(title=f"Padded Maxwell mesh: {solver.quality.cell_count:,} cells",
69                xlabel="Padded distance (km)", ylabel="Depth (km)")
70    axes[2].invert_yaxis()
71    axes[2].axvline(grid.x_m[0] / 1000, color="white", ls="--", lw=0.9)
72    axes[2].axvline(grid.x_m[-1] / 1000, color="white", ls="--", lw=0.9,
73                    label="geological core")
74    axes[2].legend(frameon=False, fontsize=8)
75    fig.colorbar(mesh_image, ax=axes[2], label=r"$\log_{10}\rho$ [$\Omega\cdot$m]")
76    fig.suptitle("Prior geometry becomes a physics-facing air/earth mesh", fontsize=13)
77    fig.tight_layout()
78    _save(fig, "geology_topography_mesh.png")

18.14.5. Build the Training Mesh and 2-D Maxwell Dataset#

Maxwell2DDatasetConfig turns a GeologyGrid sized to the real profile — 82 clean stations span 1616 m, a 2 km depth target is more than generous for a 15.8 Hz–10 kHz CSAMT band — into a mesh and a batch of solved realizations, exactly as 2-D Maxwell training-data generation describes:

>>> from pycsamt.ai.geology import GeologyGrid
>>> from pycsamt.ai.training.dataset2d import (
...     Maxwell2DDatasetConfig,
...     generate_2d_maxwell_dataset,
... )
>>> grid = GeologyGrid.regular_2d(
...     nx=21, nz=20, dx_m=1616.36 / 20, dz_m=2000.0 / 20
... )
>>> config = Maxwell2DDatasetConfig(
...     dataset_id="k2-tutorial",
...     grid=grid,
...     correlation_length_x_m=(200.0, 600.0),
...     correlation_length_z_m=(50.0, 200.0),
...     frequencies_hz=[
...         15.8, 31.6, 63.1, 126.0, 251.0,
...         501.0, 1000.0, 2000.0, 3980.0, 7940.0,
...     ],
...     station_x_m=grid.x_m,
...     n_realizations=1,
...     seed=0,
...     log_resistivity_mean=2.0,
...     log_resistivity_std=0.5,
...     components=("zxy",),
...     validation_fraction=0.0,
...     test_fraction=0.0,
... )
>>> dataset = generate_2d_maxwell_dataset(config)
>>> sample = dataset.samples[0]
>>> print("mesh cells:", sample.mesh_cells)
mesh cells: 58052
>>> print("relative residual:", round(sample.relative_residual, 12))
relative residual: 0.0
>>> print(
...     "resistivity range (ohm.m):",
...     round(float(sample.resistivity_ohm_m.min()), 1),
...     round(float(sample.resistivity_ohm_m.max()), 1),
... )
resistivity range (ohm.m): 4.6 2534.0

The single training-pair diagnostic above uses ten frequencies and a 21-station geometry so its mesh can be inspected quickly. The inversion below keeps the same ten-frequency teaching band but regenerates the Maxwell dataset for all 82 unique-coordinate stations. These remain Solver-neutral Maxwell contracts and 2-D Maxwell training-data generation demonstration settings, not a claim that ten frequencies or 24 realizations are sufficient for production. The single realization above already exercises the real pipeline: 58,052 mesh cells, a relative residual essentially at floating-point zero, and a resistivity range that comfortably contains the survey’s own 117 Ω·m median.

Before generating hundreds of realizations, inspect one complete supervised pair. The target is the earth model on the left; apparent resistivity and phase on the right are derived from the solved complex impedance and become the network input. They are not alternative images of the target: Maxwell physics smooths and mixes subsurface structure in a frequency-dependent way.

Correlated two-dimensional resistivity target with its Maxwell-solved apparent resistivity and phase pseudosections

One deterministic 2-D Maxwell training pair using the tutorial geometry and frequency band.#

The conductive body near 1.1 km depth produces a broad response rather than a cell-for-cell copy, while high frequencies are dominated by shallower structure. Phase contributes information that apparent resistivity alone does not preserve. The reported residual of approximately \(1.2\times10^{-14}\) is the linear-solver residual; it demonstrates that the discrete system converged, not that the mesh is free from discretization or boundary error. Those require the independent benchmarks described in Solver-neutral Maxwell contracts.

View and copy the solved training-pair codeClick to inspect and copy the complete code
 1def make_maxwell_training_pair() -> None:
 2    """Generate one solved 2-D training pair and expose model/response structure."""
 3    from pycsamt.ai.geology import GeologyGrid
 4    from pycsamt.ai.training.dataset2d import Maxwell2DDatasetConfig, generate_2d_maxwell_dataset
 5
 6    grid = GeologyGrid.regular_2d(nx=21, nz=20, dx_m=80.8, dz_m=100.0)
 7    frequencies = np.array([15.8, 31.6, 63.1, 126, 251, 501, 1000, 2000, 3980, 7940])
 8    config = Maxwell2DDatasetConfig(
 9        dataset_id="corrected-edi-tutorial-figure", grid=grid,
10        correlation_length_x_m=(200.0, 600.0),
11        correlation_length_z_m=(50.0, 200.0), frequencies_hz=frequencies,
12        station_x_m=grid.x_m, n_realizations=1, seed=23,
13        log_resistivity_mean=2.0, log_resistivity_std=0.5,
14        components=("zxy",), validation_fraction=0, test_fraction=0,
15    )
16    sample = generate_2d_maxwell_dataset(config).samples[0]
17    impedance = sample.survey.impedance[:, :, 0]
18    mu0 = 4e-7 * np.pi
19    apparent = np.abs(impedance) ** 2 / (2 * np.pi * frequencies[None, :] * mu0)
20    phase = np.angle(impedance, deg=True)
21
22    fig, axes = plt.subplots(1, 3, figsize=(13.2, 4.5))
23    image = axes[0].imshow(np.log10(sample.resistivity_ohm_m), aspect="auto",
24                           extent=(0, 1.616, 2.0, 0), cmap="viridis_r")
25    axes[0].set(title="Target earth model", xlabel="Distance (km)", ylabel="Depth (km)")
26    fig.colorbar(image, ax=axes[0], label=r"$\log_{10}\rho$ [$\Omega\cdot$m]")
27    for ax, values, title, label, cmap in (
28        (axes[1], np.log10(apparent).T, "Maxwell input: apparent resistivity",
29         r"$\log_{10}\rho_a$ [$\Omega\cdot$m]", "viridis_r"),
30        (axes[2], phase.T, "Maxwell input: impedance phase", "Phase (degrees)", "twilight"),
31    ):
32        response_image = ax.imshow(values, origin="lower", aspect="auto",
33                                   extent=(0, 1.616, np.log10(frequencies[0]),
34                                           np.log10(frequencies[-1])), cmap=cmap)
35        ax.set(xlabel="Station distance (km)", ylabel=r"$\log_{10}$ frequency (Hz)",
36               title=title)
37        fig.colorbar(response_image, ax=ax, label=label)
38    fig.suptitle(f"One supervised pair: {sample.mesh_cells:,} mesh cells, "
39                 f"solver residual={sample.relative_residual:.1e}", fontsize=12.5)
40    fig.tight_layout()
41    _save(fig, "maxwell_training_pair.png")

18.14.6. Run 2-D AI Inversion#

Inv2DAgent wraps exactly the steps above — build the mesh, generate realizations, train, predict on the real pseudosection — behind the physics="mt2d" execution contract introduced in Architecture roadmap:

>>> import numpy as np
>>> from pycsamt.agents import Inv2DAgent
>>> print("raw EDI sites / unique-coordinate sites:", len(sites), len(clean))
raw EDI sites / unique-coordinate sites: 86 82
>>> freqs = np.array([
...     15.8, 31.6, 63.1, 126.0, 251.0,
...     501.0, 1000.0, 2000.0, 3980.0, 7940.0,
... ])
>>> agent = Inv2DAgent(
...     physics="mt2d",
...     n_depth=20,
...     n_stations_per_profile=len(clean),
...     n_train_profiles=24,
...     epochs=30,
...     depth_max=2000.0,
...     station_spacing_m=20.2,
...     correlation_length_x_m=(200.0, 600.0),
...     correlation_length_z_m=(50.0, 200.0),
...     log_resistivity_mean=2.0,
...     log_resistivity_std=0.5,
...     lambda_x=0.01,
...     lambda_z=0.005,
...     lambda_tv=0.002,
...     mesh_safety_factor=4.0,
... )
>>> result = agent.execute({
...     "sites": clean,
...     "freqs": freqs,
...     "topography": True,
... })
>>> print("status:", result.status)
status: success
>>> print("global RMS:", round(result.data["rms_global"], 3))
global RMS: 2.106
>>> recovery = {
...     key: round(value, 3) if isinstance(value, float) else value
...     for key, value in result.data["mt2d_recovery"].items()
... }
>>> print("held-out recovery:", recovery)
held-out recovery: {'rmse': 0.518, 'mae': 0.418, 'r2': -0.075, 'n_samples': 2}

station_spacing_m=20.2 is the median spacing of the 82 distinct station coordinates. The directory currently contains 86 EDI files, but four pairs occupy identical coordinates; retaining both members of a pair would create zero-length cells and duplicate observations at one abscissa. The earlier demonstration selected every fourth clean station and therefore showed only 21 markers. That decimation has been removed from the primary result. mesh_safety_factor=4 keeps this executed tutorial near a three-minute runtime; it reduces the lateral domain margin from the package default of eight and is not a production choice until the mesh-sensitivity benchmark passes for the survey band. For a full run, generate and cache the larger Maxwell dataset offline with the default safety factor, then train repeatedly without resolving every realization.

Training loss must be inspected before the predicted section. The fitted inverter remains available in the result, so the numerical history is not hidden inside the agent:

>>> history = result.data["inverter"]._history
>>> sorted(history)
['train_loss', 'val_loss']
>>> len(history["train_loss"]), len(history["val_loss"])
(13, 13)

Early stopping ended this captured run after thirteen of the requested thirty epochs. Requesting thirty epochs is an upper bound, not evidence that thirty updates were useful. The exact curves change because the agent does not yet expose a training seed; the executed figure and held-out verdict must be archived together for each run.

K2 U-Net training and validation loss together with held-out RMSE, MAE, and R squared recovery metrics

Executed K2 learning curves and held-out synthetic recovery from the same physics="mt2d" run.#

Training loss trends downward while validation loss oscillates strongly after the fourth epoch. That gap is the onset of memorizing a tiny 24-realization training set. The right panel reaches the more important conclusion: on all 82 unique-coordinate stations, field RMS rises to 2.106 and the prediction extends from -2.47 to 5.25 in log-resistivity. Held-out \(R^2=-0.075\) is worse than predicting the target mean. The full-line result is therefore a transparent demonstration-scale failure, not an accepted interpretation. Increasing epochs alone would deepen the gap. Cache a larger set of independent Maxwell realizations, repeat seeds, and re-evaluate an untouched test partition.

View the complete 2-D run and diagnostic-figure codeClick to inspect and copy the complete code
 1def make_inv2d_topography() -> None:
 2    """Real physics="mt2d" Inv2DAgent run on all 82 unique K2 stations."""
 3    from pycsamt.agents import Inv2DAgent
 4
 5    clean = _k2_clean_sites()
 6    freqs = _K2_FULL_FREQS_HZ[::3]
 7    agent = Inv2DAgent(
 8        physics="mt2d",
 9        n_depth=20,
10        n_stations_per_profile=len(clean),
11        n_train_profiles=24,
12        epochs=30,
13        depth_max=2000.0,
14        station_spacing_m=20.2,
15        correlation_length_x_m=(200.0, 600.0),
16        correlation_length_z_m=(50.0, 200.0),
17        log_resistivity_mean=2.0,
18        log_resistivity_std=0.5,
19        lambda_x=0.01,
20        lambda_z=0.005,
21        lambda_tv=0.002,
22        mesh_safety_factor=4.0,
23    )
24    result = agent.execute({"sites": clean, "freqs": freqs, "topography": True})
25    if result.status == "failed":
26        raise RuntimeError(result.error)
27    fig = result["figures"]["topography_section"]
28    _save(fig, "inv2d_topography_section.png")
29    print(
30        "K2 2-D:",
31        {"rms_global": result.data["rms_global"],
32         "recovery": result.data.get("mt2d_recovery"),
33         "log10_rho_range": (
34             float(np.nanmin(result.data["pred_section"])),
35             float(np.nanmax(result.data["pred_section"])),
36         )},
37    )
38    history = result.data["inverter"]._history
39    train = np.asarray(history["train_loss"])
40    valid = np.asarray(history["val_loss"])
41    epoch = np.arange(1, len(train) + 1)
42    fig, axes = plt.subplots(1, 2, figsize=(10.8, 4.2))
43    axes[0].plot(epoch, train, "o-", color="#2563eb", label="training")
44    axes[0].plot(epoch, valid, "s-", color="#f15a29", label="validation")
45    axes[0].set(xlabel="Epoch", ylabel="Loss", title="K2 Maxwell-trained U-Net history")
46    axes[0].grid(alpha=0.25)
47    axes[0].legend(frameon=False)
48    recovery = result.data.get("mt2d_recovery") or {}
49    metric_names = ["rmse", "mae", "r2"]
50    metric_values = [float(recovery.get(name, np.nan)) for name in metric_names]
51    axes[1].bar(metric_names, metric_values,
52                color=["#2563eb", "#60a5fa", "#dc2626"])
53    axes[1].axhline(0, color="#111827", lw=1)
54    axes[1].set(ylabel="Metric value", title="Held-out synthetic recovery")
55    axes[1].grid(alpha=0.25, axis="y")
56    fig.suptitle("Training fit and recovery skill must be read together", fontsize=12.5)
57    fig.tight_layout()
58    _save(fig, "inv2d_training_and_recovery.png")
2-D Inv2DAgent U-Net section for the K2 line, draped below real station topography.

physics="mt2d" U-Net section for all 82 unique-coordinate K2 stations, draped below the line’s own elevation.#

Every inverted station is represented by a downward triangle. Only a readable subset of station names is printed by the shared station-rendering preset; those labels are ticks for orientation, not the list of stations used by the inversion. Thus 82 markers and model columns are present even though only about a dozen names are visible.

The section visibly follows the real terrain — the drape comes from topography=True resolving each station’s own elevation and chainage, exactly as resolve_agent_topography() documents. What the numbers say is more sobering than the figure alone. mt2d_recovery is recovery_report() run on a held-out synthetic realization with known truth — see Recovery, residual, and OOD diagnostics for what this check computes and why Inv2DAgent already calls it automatically whenever physics="mt2d" has a held-out split to check against — and at only 24 training realizations its \(R^2\) is negative: worse than predicting the mean, on a sample of two held-out realizations too small to be conclusive on its own but entirely consistent with training on two dozen realizations rather than the “hundreds to thousands… a training dataset needs” that Architecture roadmap states plainly is the realistic scale for a genuine Maxwell-solved training set. This is exactly the number recovery_report() exists to surface before a fast section is mistaken for a validated one. Re-running this exact block will not reproduce these digits bit for bit — neither agent pins a training seed. The corrected full-line geometry exposes rather than repairs the weak recovery: negative \(R^2\), an RMS above two log decades, and extreme resistivities are rejection evidence. This section is a diagnostic candidate, not a released earth model.

18.14.7. Run 3-D AI Inversion#

Inv3DAgent takes a structurally different path: rather than a genuinely 2-D Maxwell solve, it tiles independent 1-D forward models across every station and lets a graph convolutional network share information between neighbours within a configurable radius, as AI inversion agents describes. It reads real per-station coordinates from the EDI headers directly, so the full 82-station clean line can be used at once:

>>> import numpy as np
>>> from pycsamt.agents import Inv3DAgent
>>> lat = np.array([s.coords[0] for s in clean])
>>> lon = np.array([s.coords[1] for s in clean])
>>> elevation_m = np.array([s.coords[2] for s in clean])
>>> lat0 = np.radians(lat[0])
>>> x_m = (lon - lon[0]) * 111_320.0 * np.cos(lat0)
>>> y_m = (lat - lat[0]) * 110_574.0
>>> seg_m = np.sqrt(np.diff(x_m) ** 2 + np.diff(y_m) ** 2)
>>> chainage_km = np.concatenate([[0.0], np.cumsum(seg_m)]) / 1000.0
>>> freqs_full = np.array([
...     15.8, 20.0, 25.1, 31.6, 39.8, 50.1, 63.1, 79.4, 100.0,
...     126.0, 158.0, 200.0, 251.0, 316.0, 398.0, 501.0, 631.0,
...     794.0, 1000.0, 1260.0, 1580.0, 2000.0, 2510.0, 3160.0,
...     3980.0, 5010.0, 6310.0, 7940.0, 10000.0,
... ])
>>> agent3d = Inv3DAgent(
...     n_layers=6,
...     epochs=30,
...     n_train_profiles=150,
...     n_mc=20,
...     radius=300.0,
...     depth_max=2000.0,
... )
>>> result3d = agent3d.execute({
...     "sites": clean,
...     "freqs": freqs_full,
...     "topography": {
...         "elevation_m": elevation_m,
...         "chainage_km": chainage_km,
...     },
... })
>>> off_diag = (result3d.data["adjacency"] > 0).sum(axis=1) - 1
>>> print("status:", result3d.status)
status: success
>>> print(
...     "mean / min / max neighbours:",
...     round(float(off_diag.mean()), 2),
...     int(off_diag.min()),
...     int(off_diag.max()),
... )
mean / min / max neighbours: 20.24 11 24
>>> print("global RMS:", round(result3d.data["rms_global"], 3))
global RMS: 8.02

freqs_full is all 29 real K2 frequencies here — the tiled 1-D solves an Inv3DAgent run needs are far cheaper per realization than a 2-D Maxwell mesh, so the full band is affordable. elevation_m and chainage_km are supplied explicitly (computed from clean’s own ordered coordinates) rather than left to the default sites-derived extraction, because that extraction requires a strictly increasing chainage and would otherwise refuse to render on the same duplicate-coordinate stations already dropped above. A radius of 300 m against a ~20 m median spacing connects each station to roughly twenty neighbours on either side — dense enough for the GCN to average across real local noise, not so dense that the graph collapses to one node.

K2 station graph adjacency, connected-neighbour counts, and Monte Carlo dropout uncertainty by station and depth

Internal spatial graph and predictive spread from the executed Inv3DAgent run.#

The banded adjacency matrix confirms that information travels along nearby stations, while end stations have fewer neighbours and therefore a different context from central nodes. The uncertainty panel is smooth and small, but it is conditional on this graph model and its tiled 1-D training physics. It does not turn the workflow into a 3-D Maxwell inversion. Calling this output “3-D” describes the spatial graph and output organization; the forward model remains the limitation stated in Architecture roadmap.

View the complete graph and uncertainty diagnostic codeClick to inspect and copy the complete code
 1def make_inv3d_graph_diagnostic() -> None:
 2    """Real Inv3DAgent (GCN) run on the full 82-station clean K2 line."""
 3    from pycsamt.agents import Inv3DAgent
 4
 5    clean = _k2_clean_sites()
 6    lat = np.array([s.coords[0] for s in clean])
 7    lon = np.array([s.coords[1] for s in clean])
 8    elev = np.array([s.coords[2] for s in clean])
 9    lat0 = np.radians(lat[0])
10    x_m = (lon - lon[0]) * 111_320.0 * np.cos(lat0)
11    y_m = (lat - lat[0]) * 110_574.0
12    chain_km = np.concatenate(
13        [[0.0], np.cumsum(np.sqrt(np.diff(x_m) ** 2 + np.diff(y_m) ** 2))]
14    ) / 1000.0
15    agent = Inv3DAgent(
16        n_layers=6, epochs=30, n_train_profiles=150, n_mc=20,
17        radius=300.0, depth_max=2000.0,
18    )
19    result = agent.execute({
20        "sites": clean,
21        "freqs": _K2_FULL_FREQS_HZ,
22        "topography": {"elevation_m": elev, "chainage_km": chain_km},
23    })
24    if result.status == "failed":
25        raise RuntimeError(result.error)
26    raw_log_rho = np.asarray(result.data["pred_rho"])
27    physical = np.isfinite(raw_log_rho) & (raw_log_rho >= 0.0) & (raw_log_rho <= 5.0)
28    bounded_log_rho = np.where(physical, raw_log_rho, np.nan)
29    from pycsamt.topo import plot_topo_section
30
31    rejected_model = {
32        "pred_rho": bounded_log_rho,
33        "depths_km": result.data["depths_km"],
34        "station_names": result.data["station_names"],
35        "rms_global": result.data["rms_global"],
36    }
37    ax = plot_topo_section(
38        rejected_model,
39        elevation=elev,
40        chainage=chain_km,
41        station_names=result.data["station_names"],
42        station_x=chain_km,
43        topo_source="array",
44        model_unit="km",
45        depth_max=2.0,
46        title="Rejected GCN candidate: only physically bounded cells are shown",
47    )
48    ax.text(
49        0.5, 0.96,
50        f"REJECTED — {100 * (1 - physical.mean()):.1f}% of cells outside "
51        r"$1$–$10^5\ \Omega\,\mathrm{m}$ display bounds",
52        transform=ax.transAxes, ha="center", va="top", color="white",
53        fontsize=10, fontweight="bold",
54        bbox={"boxstyle": "round,pad=0.35", "facecolor": "#b91c1c",
55              "edgecolor": "white", "alpha": 0.94},
56    )
57    # Deliberately do not publish a subsurface section from a rejected
58    # candidate.  The raw values are retained below for gate diagnostics.
59    plt.close(ax.get_figure())
60    print(
61        "K2 graph candidate:",
62        {"rms_global": result.data["rms_global"],
63         "log10_rho_range": (float(np.nanmin(raw_log_rho)),
64                              float(np.nanmax(raw_log_rho))),
65         "uncertainty_range": (
66             float(np.nanmin(result.data["pred_uncertainty"])),
67             float(np.nanmax(result.data["pred_uncertainty"])),
68         ),
69         "fraction_outside_display_bounds": float(1 - physical.mean())},
70    )
71    adjacency = np.asarray(result.data["adjacency"])
72    uncertainty = np.asarray(result.data["pred_uncertainty"])
73    neighbours = (adjacency > 0).sum(axis=1) - 1
74    fig, axes = plt.subplots(1, 3, figsize=(13.0, 4.3))
75    axes[0].imshow(adjacency, origin="lower", aspect="auto", cmap="Blues")
76    axes[0].set(xlabel="Station node", ylabel="Station node",
77                title="Radius graph adjacency")
78    axes[1].plot(np.arange(len(neighbours)), neighbours, color="#2563eb")
79    axes[1].axhline(neighbours.mean(), color="#f15a29", ls="--",
80                    label=f"mean={neighbours.mean():.1f}")
81    axes[1].set(xlabel="Station node", ylabel="Connected neighbours",
82                title="Spatial context varies at line ends")
83    axes[1].legend(frameon=False)
84    axes[1].grid(alpha=0.2)
85    image = axes[2].imshow(uncertainty.T, origin="upper", aspect="auto",
86                           extent=(0, uncertainty.shape[0] - 1, 2.0, 0), cmap="magma")
87    axes[2].set(xlabel="Station node", ylabel="Depth (km)",
88                title="MC-dropout spread")
89    fig.colorbar(image, ax=axes[2], label=r"$\sigma(\log_{10}\rho)$")
90    fig.suptitle("The 3-D-labelled workflow is a graph model, not a 3-D Maxwell solve",
91                 fontsize=12.5)
92    fig.tight_layout()
93    _save(fig, "inv3d_graph_uncertainty.png")

No subsurface section is published for this run. A software status of "success" says that execution completed; it does not certify a geological model. The scientific gate is therefore applied to the unmodified prediction before any section is rendered:

>>> log10_rho = result3d.data["pred_rho"]
>>> unc = result3d.data["pred_uncertainty"]
>>> print(
...     "log10 resistivity min / max:",
...     round(float(log10_rho.min()), 2),
...     round(float(log10_rho.max()), 2),
... )
log10 resistivity min / max: -16.80 2.79
>>> print(
...     "MC-dropout sigma min / max:",
...     round(float(np.nanmin(unc)), 3),
...     round(float(np.nanmax(unc)), 3),
... )
MC-dropout sigma min / max: 0.040 0.210

result3d.data["rms_global"] — a real forward-modelled misfit in log₁₀(Ω·m), from MT1DForward() run on the predicted layered model at each station’s own observed frequencies — is 8.02, meaning the reconstructed response misses the observed apparent resistivity by an average of eight orders of magnitude. The predicted log-resistivity itself ranges from -16.80 to 2.79, i.e. roughly \(10^{-17}\) to \(6\times10^2\ \Omega\,\mathrm{m}\): no rock produces the lower end of that range. This is a network extrapolating far outside anything it saw during training, not a subtle fitting problem. The exact exponents are, again, seed dependent — every run observed while building this tutorial has landed somewhere between roughly \(10^{-30}\) and \(10^{13}\ \Omega\,\mathrm{m}\) — but rms_global staying pinned at 8.02 across those very different extremes is itself telling: a forward-modelled misfit this severe is not sensitive to exactly which implausible value the network lands on.

In addition, 83.3% of the cells lie outside the declared \(1\)\(10^5\ \Omega\,\mathrm{m}\) interval. This is why the tutorial stops at diagnostics. Clipping, masking, or rescaling the candidate would change its appearance, not its scientific validity.

18.14.8. Run the Experimental Candidate Outside the Tutorial#

Readers who want to reproduce or test the current graph workflow should run it in a quarantine directory, separate from accepted models and report figures. From the repository root:

python docs/scripts/run_ai_inv3d_candidate.py k2_corrected --output runs/k2_graph_candidate --epochs 30 --profiles 150 --radius 300 --depth-max 2000

The command finishes with exit status 2 by design and prints paths such as:

candidate directory: .../runs/k2_graph_candidate
gate report: .../runs/k2_graph_candidate/candidate_gate.json
scientific release: rejected

Open candidate_gate.json first. It records the raw RMS, resistivity range, fraction outside physical bounds, uncertainty range, thresholds, and each numerical decision. Even if those numerical checks pass on another survey, physics_gate remains false because the present backend does not solve the 3-D Maxwell equations. Keep the directory for method development and comparison, but do not copy a candidate section into documentation, a client report, or an interpretation project. Promotion requires a validated 3-D forward operator, response-space hold-out tests, acceptable OOD scores, and survey-specific thresholds chosen before inspecting the prediction.

View and copy the external candidate runnerClick to inspect and copy the complete code
  1"""Run the experimental graph candidate without publishing it as 3-D inversion.
  2
  3The command writes a machine-readable gate report into a quarantine directory.
  4It intentionally never promotes or renders a subsurface section: the current
  5Inv3DAgent uses graph learning with tiled 1-D MT physics, not a 3-D Maxwell
  6forward operator.
  7"""
  8
  9from __future__ import annotations
 10
 11import argparse
 12import json
 13import sys
 14from pathlib import Path
 15
 16import numpy as np
 17
 18ROOT = Path(__file__).resolve().parents[2]
 19sys.path.insert(0, str(ROOT))
 20
 21from pycsamt.agents import Inv3DAgent
 22from pycsamt.emtools import ensure_sites
 23
 24
 25def parse_args() -> argparse.Namespace:
 26    parser = argparse.ArgumentParser(description=__doc__)
 27    parser.add_argument("edi_directory", type=Path)
 28    parser.add_argument("--output", type=Path, default=Path("runs/inv3d_candidate"))
 29    parser.add_argument("--epochs", type=int, default=30)
 30    parser.add_argument("--profiles", type=int, default=150)
 31    parser.add_argument("--radius", type=float, default=300.0)
 32    parser.add_argument("--depth-max", type=float, default=2000.0)
 33    parser.add_argument("--n-mc", type=int, default=20)
 34    parser.add_argument("--rms-max", type=float, default=2.0)
 35    parser.add_argument("--rho-min", type=float, default=1.0)
 36    parser.add_argument("--rho-max", type=float, default=1.0e5)
 37    parser.add_argument("--max-outside-fraction", type=float, default=0.0)
 38    return parser.parse_args()
 39
 40
 41def main() -> int:
 42    args = parse_args()
 43    args.output.mkdir(parents=True, exist_ok=True)
 44    sites = ensure_sites(args.edi_directory, recursive=True, verbose=0).ordered()
 45    agent = Inv3DAgent(
 46        n_layers=6,
 47        epochs=args.epochs,
 48        n_train_profiles=args.profiles,
 49        n_mc=args.n_mc,
 50        radius=args.radius,
 51        depth_max=args.depth_max,
 52    )
 53    result = agent.execute({"sites": sites, "topography": True})
 54    if result.status == "failed":
 55        raise RuntimeError(result.error)
 56
 57    log10_rho = np.asarray(result.data["pred_rho"], dtype=float)
 58    uncertainty = np.asarray(result.data["pred_uncertainty"], dtype=float)
 59    lo, hi = np.log10([args.rho_min, args.rho_max])
 60    in_bounds = np.isfinite(log10_rho) & (log10_rho >= lo) & (log10_rho <= hi)
 61    outside_fraction = float(1.0 - in_bounds.mean())
 62    rms = float(result.data["rms_global"])
 63
 64    numerical_gates = {
 65        "finite_prediction": bool(np.isfinite(log10_rho).all()),
 66        "finite_uncertainty": bool(np.isfinite(uncertainty).all()),
 67        "rms": bool(np.isfinite(rms) and rms <= args.rms_max),
 68        "physical_bounds": bool(outside_fraction <= args.max_outside_fraction),
 69    }
 70    report = {
 71        "execution_status": result.status,
 72        "scientific_release": "rejected",
 73        "reason": (
 74            "The current candidate uses a station graph and tiled 1-D MT "
 75            "forward responses; it is not validated 3-D Maxwell inversion."
 76        ),
 77        "forward_physics": "tiled_mt1d_graph",
 78        "n_stations": len(sites),
 79        "measurements": {
 80            "rms_global": rms,
 81            "log10_rho_min": float(np.nanmin(log10_rho)),
 82            "log10_rho_max": float(np.nanmax(log10_rho)),
 83            "outside_physical_bounds_fraction": outside_fraction,
 84            "uncertainty_min": float(np.nanmin(uncertainty)),
 85            "uncertainty_max": float(np.nanmax(uncertainty)),
 86        },
 87        "thresholds": {
 88            "rms_max": args.rms_max,
 89            "rho_min_ohm_m": args.rho_min,
 90            "rho_max_ohm_m": args.rho_max,
 91            "max_outside_fraction": args.max_outside_fraction,
 92        },
 93        "numerical_gates": numerical_gates,
 94        "all_numerical_gates_pass": all(numerical_gates.values()),
 95        "physics_gate": False,
 96    }
 97    report_path = args.output / "candidate_gate.json"
 98    report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
 99    print(f"candidate directory: {args.output.resolve()}")
100    print(f"gate report: {report_path.resolve()}")
101    print("scientific release: rejected")
102    return 2
103
104
105if __name__ == "__main__":
106    raise SystemExit(main())

The genuinely uncomfortable part is the MC-dropout spread above: predict_with_uncertainty()’s Monte Carlo dropout spread is a small fraction of a log₁₀-resistivity decade everywhere — the network is, by this measure, confident. Epistemic uncertainty from dropout only describes how much the network’s own learned weights disagree with each other near the point it already landed on; it says nothing about whether that point is anywhere near the training distribution in the first place. A catastrophically wrong prediction with a small declared error bar is the single most dangerous failure mode this whole tutorial’s validation apparatus exists to catch, and MC-dropout alone — as this real run demonstrates — does not catch it.

18.14.9. Catch a Confidently Wrong Prediction#

Recovery, residual, and OOD diagnostics’s flag_out_of_distribution() is built for exactly this gap. Summarizing each station’s observed [log₁₀(apparent resistivity), phase] curve as one small feature vector (mean and spread of each) and comparing it against the same summary computed from 300 samples of the synthetic 1-D curves Inv3DAgent actually trains on gives a direct, quantitative answer to “does this station look like anything the network learned from”:

>>> from pycsamt.agents.ai_inversion import _z_to_features
>>> from pycsamt.emtools._core import _get_z_block
>>> from pycsamt.forward.batch import generate_dataset
>>> from pycsamt.ai.validation import flag_out_of_distribution
>>> n = freqs_full.size
>>> def summarize(X):
...     rho, pha = X[:, :n], X[:, n : 2 * n]
...     return np.stack(
...         [rho.mean(1), rho.std(1), pha.mean(1), pha.std(1)], axis=1
...     )
...
>>> x_obs = []
>>> for site in clean:
...     z_obj, z, fr = _get_z_block(site)
...     feat = _z_to_features(z_obj, z, fr, freqs_full)
...     x_obs.append(feat[: 2 * n])
...
>>> feat_obs = summarize(np.array(x_obs))
>>> ds = generate_dataset(
...     solver="mt1d",
...     n_samples=300,
...     freqs=freqs_full,
...     n_layers=6,
...     noise_level=0.03,
...     seed=1,
...     n_jobs=1,
...     verbose=False,
... )
>>> feat_train = summarize(ds.X[:, : 2 * n])
>>> report_ood = flag_out_of_distribution(
...     feat_obs,
...     feat_train,
...     method="mahalanobis",
...     quantile=0.95,
... )
>>> print(
...     "flagged / total:",
...     int(report_ood.flagged.sum()),
...     "/",
...     len(report_ood.flagged),
... )
flagged / total: 81 / 82
>>> print("threshold:", round(report_ood.threshold, 2))
threshold: 3.3
>>> print(
...     "score min / max:",
...     round(float(report_ood.scores.min()), 2),
...     round(float(report_ood.scores.max()), 2),
... )
score min / max: 2.99 41.57
Bar chart of Mahalanobis out-of-distribution scores for 82 K2 stations, almost all above the training-derived threshold.

Every K2 station’s Mahalanobis distance from the GCN’s training-feature distribution, sorted, against the threshold flag_out_of_distribution() derived from the training set’s own self-scores.#

Eighty-one of eighty-two stations exceed a threshold set from the training data’s own internal spread — only Z2HX051, the station sitting at the profile’s topographic high point, scores inside it. This is the quantitative version of the audit’s opening finding: a survey that is 89.8% classified 3-D, with an 84° strike disagreement between stations, does not resemble a population of independent 1-D layered soundings, which is precisely what Inv3DAgent’s training data is built from. The OOD screen would have flagged this before a single figure was drawn, using nothing but the observed data and the training configuration — no synthetic truth, no field ground truth, and no dependence on whether the final prediction happened to look plausible.

18.14.11. See Also#

Condition an MT Line With Tipper and Rotation

Prepare corrected MT data before AI or classical inversion.

Prepare an Occam2D Inversion

Classical Occam2D input preparation for comparison.

Run a Pipeline From Config

Store repeatable preprocessing before the AI inversion step.

Building a Defensible 3-D AI Inversion Problem

A focused 3-D-plus-topography AI inversion walkthrough on the bundled L18PLT line.

Recovery, residual, and OOD diagnostics

Recovery, response-residual, calibration, and out-of-distribution diagnostics in full.

Domain-gap and noise simulation

Why field surveys differ from synthetic training data, and how to quantify the gap.

AI inversion

Full AI inversion user guide.