6.2.6. Occam1D inversion#
pycsamt.models.occam1d is pyCSAMT’s Occam1D engine: it builds
native input files, runs the nonlinear smooth-model inversion, loads
results, and produces review figures for one sounding at a time. Unlike
Occam2D, which prepares files for an external Fortran executable,
Occam1D’s forward model, analytic Jacobian, and Occam iteration are
implemented natively in Python and NumPy – there is no external binary to
compile or license for the default workflow, though Occam1DRunner
can still drive one if a compiled Occam1D executable is available.
Every station is inverted independently. There is no mesh and no lateral coupling between soundings, so the regularization only penalizes roughness with depth at one station, following the same objective structure as any other Occam-family method (see objective function and (9) in Inversion):
the same RMS misfit used across pyCSAMT’s inversion engines, with \(r_i\) the residual at datum \(i\) and \(\sigma_i\) its assigned uncertainty. Occam1D searches, at each nonlinear iteration, for the smoothest layered-earth model whose predicted response drives this quantity toward a user-specified target.
6.2.6.1. When To Use Occam1D#
Occam1D is the right tool when:
soundings can be treated as independent 1-D layered earths – no profile geometry, no lateral mesh, no assumption that neighboring stations share structure;
the deliverable is a fast, reproducible smooth inversion per station, suitable for a first pass over a whole survey before committing to a 2-D or 3-D engine;
native file provenance (data, model, startup) still matters, e.g. for archiving or cross-checking against a reference Occam1D binary;
batch throughput across many stations matters more than resolving 2-D or 3-D structure – see Batch inversion below.
It is not a substitute for Occam2D or ModEM when the target structure is genuinely two- or three-dimensional: a real lateral contact or dipping body will bias each independent 1-D model in a way no amount of per-station smoothness regularization can correct, because Occam1D has no mechanism to represent structure that only a neighboring station’s data constrains.
6.2.6.2. Package Map#
Area |
Main objects |
Purpose |
|---|---|---|
Configuration |
|
Mode, layer geometry, error floors, iteration controls, native filenames. |
Input construction |
|
Build one station’s or a whole survey’s data/model/startup files from EDI or site sources. |
Native data |
|
Read/write the native sounding, layer geometry, and startup files. |
Forward physics |
|
Isotropic layered-earth impedance recursion (analytic, optionally Numba-compiled). |
Sensitivities |
|
Analytic response derivatives, plus a central-difference reference path for verification. |
Regularization |
|
Roughness penalty, linearized system assembly, ill-conditioning fallback policy. |
Native inversion |
|
The nonlinear Occam loop itself: Lagrange multiplier search, model acceptance, convergence. |
External execution |
|
Discover and launch an external |
Results |
|
Load a completed run’s iterations, response, and log; checkpoint and resume a native run. |
Diagnostics |
|
Parse response residuals and convergence history from native files. |
Plotting |
|
Model, response-fit, convergence, and combined summary figures. |
Validation |
|
Recognize and check native data, model, and startup files. |
6.2.6.3. Configuration#
Occam1DConfig is the source-of-truth object for one station’s (or one
batch’s) inversion setup. It groups four concerns: which response is
extracted from the source data, how the layer geometry is discretized, how
the nonlinear iteration is controlled, and what the native files are named.
1>>> from pycsamt.models.occam1d import Occam1DConfig
2
3>>> cfg = Occam1DConfig(
4... mode="determinant",
5... n_layers=30,
6... first_thickness=5.0,
7... depth_max=5000.0,
8... starting_resistivity=100.0,
9... target_misfit=1.5,
10... max_iterations=25,
11... )
12>>> cfg.layer_growth_factor
131.2008325637623543
14
15>>> path = cfg.to_template("occam1d.yml")
16>>> loaded = Occam1DConfig.from_file(path)
17>>> loaded.mode, loaded.n_layers, loaded.depth_max, loaded.target_misfit
18('determinant', 30, 5000.0, 1.5)
mode selects which response Occam1D fits: "xy"/"te" or
"yx"/"tm" extract one polarization, while the default
"determinant" fits the rotation-invariant determinant response
\(Z_d=\sqrt{-Z_{xy}Z_{yx}}\), which is what
pycsamt.models.occam1d.processing computes when no explicit
resistivity/phase pair is supplied. n_layers, first_thickness, and
depth_max define a geometrically growing layer stack:
layer_growth_factor is derived, not stored, specifically so these three
parameters cannot silently contradict each other. target_misfit and
max_iterations bound the nonlinear search; error_floor_rho/
error_floor_phase (relative resistivity, absolute phase in degrees) set
the minimum uncertainty assigned to any observation, which matters because
Occam1D will otherwise happily overfit an observation whose reported error
is implausibly small.
6.2.6.4. Build Input Files#
The examples below use three real soundings from a public USGS magnetotelluric
survey of the Gabbs Valley geothermal area, Nevada (Peacock et al., 2021,
data/gv_data/README.md) – gv100, gv130, and gv163, spanning
the low, middle, and high end of the 59-station numbering. Real field data
(broadband, remote-reference processed, with some noisy long-period
estimates) exercises the missing-observation handling and candidate
rejection that a clean synthetic sounding would not.
1>>> from pycsamt.models.occam1d import Occam1DBatch, Occam1DConfig
2>>> from pycsamt.seg.edi import EDIFile
3
4>>> stations = ["gv100", "gv130", "gv163"]
5>>> sources = [
6... EDIFile(f"data/gv_data/gv_final_edi/{s}.edi") for s in stations
7... ]
8>>> config = Occam1DConfig(
9... mode="determinant",
10... n_layers=30,
11... first_thickness=5.0,
12... depth_max=5000.0,
13... starting_resistivity=100.0,
14... target_misfit=1.5,
15... max_iterations=25,
16... )
17>>> batch = Occam1DBatch(
18... sources, "occam1d-inversion", config=config
19... ).build_all()
20>>> batch.is_ready, len(batch.builders)
21(True, 3)
Each station gets its own subdirectory (occam1d-inversion/gv100/, and so
on), containing the three native files plus a JSON manifest recording the
effective configuration and checksums:
1>>> from pycsamt.models.occam1d import detect_file_type
2
3>>> for name in ["Occam1DData", "Occam1DModel", "Startup"]:
4... print(name, detect_file_type(f"occam1d-inversion/gv100/{name}"))
5Occam1DData Occam1DFileType.DATA
6Occam1DModel Occam1DFileType.MODEL
7Startup Occam1DFileType.STARTUP
gv100 keeps all 48 of its frequencies; gv130 and gv163 lose two
of theirs. That is expected, not a bug to work around: real EDI files mark a
missing transfer-function estimate with the format’s own EMPTY sentinel
(1e32) rather than omitting the row, and the survey’s own metadata notes
some long-period estimates are less robust. extract_sounding filters
observations that are not finite and positive before a data row is ever
written, so a station with a few unusable long-period estimates builds
cleanly with a slightly shorter frequency list instead of propagating a
sentinel value into the inversion.
6.2.6.5. Running The Inversion#
Occam1DInversion runs the nonlinear Occam loop directly against the
objects an Occam1DInputBuilder (or a batch’s builders) already prepared
– no subprocess, no native file round-trip required to get started:
1>>> from pycsamt.models.occam1d import Occam1DInversion
2
3>>> builder = batch.builders[0]
4>>> inversion = Occam1DInversion(
5... builder.data, builder.model,
6... config=builder.config, startup=builder.startup,
7... )
8>>> result = inversion.run()
9>>> print(inversion.result_summary(result))
10pyCSAMT native Occam1D inversion
11 station : gv100
12 mode : determinant
13 frequencies : 48
14 observations : 96
15 layers : 30
16 status : max_iterations
17 iterations : 25
18 initial RMS : 10.236448
19 final RMS : 1.6218702
20 target RMS : 1.5
21 roughness : 25.133911
22 multiplier : 0.05
23 rejected : 2247
24 failed steps : 0
25 message : The maximum iteration count was reached.
Each iteration evaluates the current model’s Lagrange multiplier
against a small logarithmically spaced grid of trial factors (13 by
default), solves the regularized linear system at every trial, scores every
resulting candidate through the nonlinear forward model, and accepts the
one that best trades off roughness against misfit – never a linearized
estimate. The 2247 rejected candidates above are not a sign anything went
wrong: at 13 trials per iteration across 25 iterations, most trials are
supposed to lose to a better one every step, and every rejection is kept
in result.rejected_candidates for exactly this kind of post-hoc check
rather than discarded silently.
Behind inversion.run(), the same isotropic layered-earth recursion
computes both the forward response and, simultaneously, its analytic
derivative with respect to every layer’s log-resistivity. For angular
frequency \(\omega\), layer resistivity \(\rho_j\), thickness
\(h_j\), and vacuum permeability \(\mu_0\), define
starting from the basement impedance \(Z_N=\eta_N\) and recursing upward to the surface impedance \(Z_1\). Apparent resistivity is \(|Z_1|^2/(\mu_0\omega)\) and phase is \(\arg(Z_1)\). This is the same recursion Forward Modelling uses for synthetic 1-D responses; Occam1D’s contribution is the outer nonlinear loop and regularization around it, not a separate physics implementation. When Numba is installed (an optional accelerator, not a hard dependency), this recursion runs as compiled per-frequency scalar code instead of NumPy calls on small arrays – roughly an order of magnitude faster per evaluation on real survey-sized soundings, with identical results either way, because the analytic Jacobian and every candidate evaluation call it once per trial multiplier.
For the determinant mode used above, the two off-diagonal impedance components combine as
a rotation-invariant response that is less sensitive to a purely 2-D or 3-D
distortion of any single component than xy or yx alone – part of
why it is Occam1D’s default for field data where the true dimensionality is
not yet known.
gv100’s combined summary figure: recovered resistivity-depth model
(left), observed versus modeled apparent resistivity and phase (center),
and RMS convergence history (right).#
The model panel shows resistive near-surface layers giving way to a
conductive zone around 300-600 m depth, then increasing resistivity with
depth – broadly consistent with a shallow volcanic/sedimentary cover over
more resistive basement, though a single 1-D sounding cannot by itself
distinguish a genuine conductive layer from smoothed lateral structure. The
fit panel shows the model tracking the observed curve well through the
mid-band, with the largest departures at the shortest and longest periods,
exactly where the survey metadata flags data quality as weaker; the
convergence panel shows RMS dropping sharply in the first few iterations
and then flattening well above the target of 1.5, consistent with real
noise rather than a starting-model or convergence problem. This combined
figure comes directly from
save_main_images() — see
Text and image products below –
which is built from the same public PlotModel/PlotResponse/
PlotConvergence helpers used individually elsewhere in the package.
result is an immutable Occam1DInversionResult
carrying the full accepted-model history (result.iterations), the
starting and final models (result.initial/result.final, each an
Occam1DIteration with .parameters in
log10 resistivity, .rms, and .roughness), why the run stopped
(result.convergence, an Occam1DConvergence
value such as TARGET, MAX_ITERATIONS, or STAGNATED), and the
full rejection/failure ledgers used above. Call
restart() to checkpoint a run
and continue it later with an absolute, not additive, iteration budget.
An external, separately compiled Occam1D-compatible binary remains an
option through Occam1DRunner, which
discovers an executable (explicit path, the run directory, or PATH) and
launches it as a subprocess against the same native files:
1>>> from pycsamt.models.occam1d import Occam1DRunner
2
3>>> runner = Occam1DRunner(
4... "occam1d-inversion/gv100", binary_path="/opt/occam1d/Occam1D"
5... )
6>>> runner.run(target_misfit=1.5, max_iterations=25)
6.2.6.6. Batch inversion#
Because every station is independent, an entire survey can be built and
inverted in two calls. Occam1DBatch.build_all prepares every station’s
native files (shown above); invert_all then runs each one’s inversion
to completion:
1>>> outcome = batch.invert_all(n_jobs=1, export_text=False)
2>>> for station, summary in sorted(outcome["results"].items()):
3... print(
4... f"{station}: status={summary['status']} "
5... f"iterations={summary['iterations']} "
6... f"final_rms={summary['final_rms']:.3f}"
7... )
8gv100: status=max_iterations iterations=25 final_rms=1.622
9gv130: status=max_iterations iterations=25 final_rms=3.755
10gv163: status=max_iterations iterations=25 final_rms=2.523
The three stations’ final models, one column per station on a single shared depth axis. Each panel is an independent inversion – Occam1D never shares information between stations – yet lining them up this way makes it easy to see that all three place a conductive zone within the top kilometre before trending resistive at depth, a first, informal check for whether the along-valley structure is at least broadly consistent before committing to a laterally coupled 2-D or 3-D inversion.#
gv130 and gv163 converge to a visibly higher final RMS than
gv100. That gap is a real, station-specific data-quality difference
worth carrying into the interpretation, not a bug in the batch call –
invert_all runs each station’s inversion with identical settings and
reports whatever RMS that station’s own data actually supports.
Each station’s data, model, and startup are re-read from the files
build_all already wrote rather than passed in memory, so
invert_all(n_jobs=-1) can dispatch every station to a separate process
through joblib without pickling
problems. Stations are fully independent, so this scales close to linearly
once a survey has comfortably more stations than worker processes; for a
handful of stations, process start-up cost dominates and sequential
execution (n_jobs=1, the default) is competitive. export_text/
export_images control whether each station also gets its usual
CSV/JSON/PNG products written during the same call; both default to
lighter settings suited to a first pass over a large survey.
joblib is an optional dependency (pip install pycsamt[perf]);
without it, n_jobs other than 1 falls back to a sequential loop and
a warning. Numba, also part of the perf extra, is the accelerator
introduced above – both are soft dependencies specifically so a plain
pip install pycsamt keeps working, only slower.
6.2.6.7. Text and image products#
1>>> text_paths = inversion.export_result("model-text")
2>>> image_paths = inversion.save_main_images("model-image")
3>>> sorted(text_paths)
4['failures', 'iterations', 'metadata', 'model', 'rejected', 'response', 'summary']
The image export writes stable model, response, convergence, and combined summary PNG files (the last is what the figure above uses). Apparent resistivity is displayed in ohm metres, phase in degrees, frequency in hertz, and depth in metres. The model panel follows the pyCSAMT convention of positive depth downward and a filled downward station marker at the surface. The text export writes the same information as CSV/JSON, suitable for downstream scripting without re-parsing native files or re-running the inversion.
6.2.6.8. Common Mistakes#
- Treating rejected candidates as failures
A high
rejectedcount inresult_summaryis routine: most of the 13 trial multipliers evaluated per iteration are expected to lose to a better one every step. Look atresult.failed_iterations(candidates that could not be evaluated at all) for a genuine problem instead.- Reading a smooth conductive layer as a sharp boundary
Occam1D’s roughness penalty spreads structure with depth unless the data actively require a sharper transition. A gradational resistivity drop in the model panel does not imply a gradational geological contact.
- Interpreting independent stations as a laterally coupled section
Nothing prevents plotting several stations’ 1-D models side by side (as above), but each one was inverted with zero knowledge of its neighbors. Apparent lateral continuity is a real observation worth checking, not a result the regularization enforced.
- Leaving error floors at survey-wide defaults for noisy long-period data
error_floor_rho/error_floor_phaseapply uniformly to every observation that passes the finite/positive filter. A station with markedly worse long-period estimates (likegv130/gv163above) may need a higher floor, or an explicitfreq_min/freq_maxrestriction, rather than letting the inversion chase noise.
6.2.6.9. Next Steps#
Occam2D – the 2-D counterpart, for genuinely laterally varying structure.
Compiling the External Solvers – building an external solver, if
Occam1DRunner’s binary path is preferred over the native engine.Inversion – the shared objective-function and regularization concepts behind every Occam-family engine in pyCSAMT.