Monitor inversion convergence#

After a ModEM or Occam-style run folder has been created, the next question is not only “did the executable finish?” The better question is:

is the inversion learning from the data in a stable and geologically useful way?

This example shows a practical monitoring workflow for an inversion run. It uses the run folders created by the previous example and builds a realistic convergence dashboard from two sources:

  • an Occam2D-style text log parsed with pycsamt.models.occam2d.OccamLog;

  • a ModEM-style normalized CSV history, useful when the backend text output is noisy or platform-dependent.

The example does not launch any external inversion executable. Instead it creates compact demonstration histories in the run folders, then shows the same checks you would apply to real output files: RMS reduction, roughness growth, step-size collapse, late-iteration stagnation, and best-iteration selection.

1. Imports and workspace paths#

import csv
import json
import os
import sys
from pathlib import Path

# sphinx-gallery executes examples without __file__ (the gallery
# runner sets the working directory to this example's folder).
try:
    EXAMPLE_DIR = Path(__file__).resolve().parent
except NameError:
    EXAMPLE_DIR = Path.cwd()

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd


def repo_root():
    root = os.environ.get("PYCSAMT_DOCS_REPO_ROOT")
    return Path(root) if root else EXAMPLE_DIR.parents[2]


ROOT = repo_root()
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from pycsamt.models.occam2d import OccamLog

workspace = EXAMPLE_DIR / "workspaces" / "l18_prepared_workspace"
table_dir = workspace / "02_tables"
run_root = workspace / "04_run_files"
figure_dir = workspace / "05_figures"

modem_dir = run_root / "modem_2d"
occam_dir = run_root / "occam2d"

for path in (modem_dir, occam_dir, figure_dir):
    path.mkdir(parents=True, exist_ok=True)

2. Check that the run folders are ready#

A convergence monitor should start from a known run folder. If the previous example has not been run, fail early with a clear instruction instead of producing misleading empty plots.

audit_file = run_root / "run_folder_audit.csv"
if not audit_file.exists():
    raise RuntimeError(
        "Run-folder audit missing. Run plot_5_create_modem_occam_run_folder.py "
        "before this convergence-monitoring example."
    )

audit = pd.read_csv(audit_file)
folder_health = (
    audit.groupby("folder")["exists"].mean().rename("completeness")
)
print("Run-folder completeness:")
print(folder_health.to_string())

if (folder_health < 1.0).any():
    missing = audit.loc[~audit["exists"], ["folder", "file"]]
    raise RuntimeError(
        "Cannot monitor an incomplete inversion folder. Missing files:\n"
        + missing.to_string(index=False)
    )

policy_file = table_dir / "inversion_data_error_policy.json"
policy = json.loads(policy_file.read_text(encoding="utf-8"))
target_rms = 1.05
Run-folder completeness:
folder
modem_2d    1.0
occam2d     1.0

3. Build demonstration convergence histories#

Real projects would replace this block with solver outputs:

  • Occam2D: read the actual LogFile.logfile with OccamLog.read;

  • ModEM: convert text logs to a table with columns like iteration, rms, objective and model_norm.

The synthetic histories below are deliberately realistic enough to teach interpretation. The RMS decreases fast at first, then slowly. Roughness and model norm rise as the inversion adds structure. A warning sign appears when RMS improvement becomes tiny while roughness still grows.

rng = np.random.default_rng(42)
iterations = np.arange(1, 19)

occam_rms = 1.02 + 2.95 * np.exp(-0.26 * iterations)
occam_rms += rng.normal(0.0, 0.018, size=iterations.size)
occam_rms = np.maximum.accumulate(occam_rms[::-1])[::-1]
occam_rms[-3:] = [1.075, 1.061, 1.057]

roughness = 0.45 + 0.22 * iterations**1.25
lagrange = 80.0 * np.exp(-0.18 * iterations) + 2.0
stepsize = np.clip(0.95 * np.exp(-0.055 * iterations), 0.18, None)
stepsize[-3:] = [0.22, 0.19, 0.17]

modem_rms = 1.04 + 3.25 * np.exp(-0.23 * iterations)
modem_rms += rng.normal(0.0, 0.022, size=iterations.size)
modem_rms = np.maximum.accumulate(modem_rms[::-1])[::-1]
modem_rms[-4:] = [1.122, 1.096, 1.089, 1.087]
objective = (modem_rms**2) * 1200.0 + 38.0 * np.log1p(iterations)
model_norm = 0.75 + 0.32 * iterations**1.18


def write_occam_demo_log(path, iters, rms, rough, mu, step):
    lines = [
        "pyCSAMT demonstration Occam2D convergence log",
        "This file mimics the important iteration markers parsed by OccamLog.",
        "",
    ]
    previous = float(rms[0] + 0.55)
    for idx, iteration in enumerate(iters):
        lines.extend(
            [
                f"STARTING R.M.S. = {previous:.6f}",
                f"** ITERATION {int(iteration)} **",
                f"TOFMU: MISFIT = {float(rms[idx] + 0.08):.6f}  "
                f"AMU = {float(mu[idx] * 1.2):.6f}",
                f"MINIMUM TOL FROM fminocc IS AT MU = {float(mu[idx]):.6f}",
                f"AND IS = {float(rms[idx]):.6f}",
                "USING 4 EVALUATIONS OF FUNCTION",
                f"STEPSIZE IS = {float(step[idx]):.6f}",
                f"ROUGHNESS IS = {float(rough[idx]):.6f}",
                "",
            ]
        )
        previous = float(rms[idx])
    path.write_text("\n".join(lines), encoding="utf-8")


occam_log_file = occam_dir / "LogFile.logfile"
write_occam_demo_log(
    occam_log_file,
    iterations,
    occam_rms,
    roughness,
    lagrange,
    stepsize,
)

modem_history_file = modem_dir / "modem_convergence_history.csv"
with modem_history_file.open("w", newline="", encoding="utf-8") as fh:
    writer = csv.DictWriter(
        fh,
        fieldnames=[
            "iteration",
            "rms",
            "objective",
            "model_norm",
            "gradient_norm",
        ],
    )
    writer.writeheader()
    for iteration, rms, obj, norm in zip(
        iterations,
        modem_rms,
        objective,
        model_norm,
    ):
        writer.writerow(
            {
                "iteration": int(iteration),
                "rms": f"{float(rms):.6f}",
                "objective": f"{float(obj):.6f}",
                "model_norm": f"{float(norm):.6f}",
                "gradient_norm": f"{float(1.4 / iteration):.6f}",
            }
        )

4. Parse logs into common monitoring tables#

OccamLog gives arrays aligned by iteration. We convert them to a table so that the Occam and ModEM histories can be audited with the same helper functions.

occam_log = OccamLog.read(occam_log_file)
print(occam_log.summary().replace("→", "->"))

occam_history = pd.DataFrame(
    {
        "backend": "Occam2D",
        "iteration": occam_log.iterations,
        "rms": occam_log.rms,
        "roughness": occam_log.roughness,
        "lagrange": occam_log.lagrange,
        "stepsize": occam_log.stepsize,
    }
)

modem_history = pd.read_csv(modem_history_file)
modem_history.insert(0, "backend", "ModEM")
modem_history["roughness"] = np.nan
modem_history["lagrange"] = np.nan
modem_history["stepsize"] = np.nan

history = pd.concat(
    [
        occam_history[
            [
                "backend",
                "iteration",
                "rms",
                "roughness",
                "lagrange",
                "stepsize",
            ]
        ],
        modem_history[
            [
                "backend",
                "iteration",
                "rms",
                "roughness",
                "lagrange",
                "stepsize",
            ]
        ],
    ],
    ignore_index=True,
)

history["rms_drop"] = history.groupby("backend")["rms"].diff(-1).abs()
history["relative_drop_pct"] = (
    history.groupby("backend")["rms"].pct_change().abs() * 100.0
)
history.loc[history["iteration"] == 1, "relative_drop_pct"] = np.nan
history["within_target"] = history["rms"] <= target_rms

history_file = run_root / "convergence_history_normalized.csv"
history.to_csv(history_file, index=False)

print("Normalized convergence history preview:")
print(history.head(8).to_string(index=False))
OccamLog: 18 iterations | initial RMS 3.3001 -> final RMS 1.0570 | best iter 18 (RMS 1.0570) | converged: False
Normalized convergence history preview:
backend  iteration      rms  roughness  lagrange  stepsize  rms_drop  relative_drop_pct  within_target
Occam2D          1 3.300087   0.670000 68.821617  0.899161  0.544971                NaN          False
Occam2D          2 2.755116   0.973251 57.814106  0.851042  0.369310          16.513837          False
Occam2D          3 2.385806   1.318609 48.619860  0.805499  0.306185          13.404517          False
Occam2D          4 2.079621   1.694508 40.940180  0.762393  0.290771          12.833608          False
Occam2D          5 1.788850   2.094884 34.525573  0.721594  0.172388          13.981923          False
Occam2D          6 1.616462   2.515912 29.167642  0.682978  0.116185           9.636806          False
Occam2D          7 1.500277   2.954928 24.692322  0.646428  0.117425           7.187611          False
Occam2D          8 1.382852   3.409955 20.954221  0.611835  0.078988           7.826888          False

5. Diagnose convergence behavior#

A single final RMS value can hide several problems. These compact checks are intentionally simple so users can adapt them to their own tolerance:

  • target reached: any iteration is at or below the requested RMS;

  • best iteration: the lowest finite RMS, not necessarily the last model;

  • stagnation: the last few iterations improve by less than a small amount;

  • roughening: roughness keeps growing after the data fit has nearly stopped improving.

def diagnose_backend(frame, target, stagnation_window=4, small_drop=0.02):
    ordered = frame.sort_values("iteration").copy()
    finite = ordered[np.isfinite(ordered["rms"])]
    if finite.empty:
        return {
            "backend": str(ordered["backend"].iloc[0]),
            "status": "FAIL",
            "best_iteration": None,
            "best_rms": np.nan,
            "final_rms": np.nan,
            "target_reached": False,
            "late_rms_drop": np.nan,
            "message": "No finite RMS values were found.",
        }

    best_row = finite.loc[finite["rms"].idxmin()]
    tail = finite.tail(stagnation_window)
    late_drop = float(tail["rms"].iloc[0] - tail["rms"].iloc[-1])
    target_reached = bool((finite["rms"] <= target).any())

    roughening = False
    if (
        "roughness" in finite
        and finite["roughness"].notna().sum() >= stagnation_window
    ):
        rough_tail = finite.dropna(subset=["roughness"]).tail(
            stagnation_window
        )
        if len(rough_tail) >= stagnation_window:
            roughening = bool(
                rough_tail["roughness"].iloc[-1]
                > 1.10 * rough_tail["roughness"].iloc[0]
            )

    if target_reached:
        status = "PASS"
        message = (
            "Target RMS was reached; inspect the best model and response fit."
        )
    elif late_drop < small_drop:
        status = "WARN"
        message = (
            "RMS improvement is small in the last iterations; consider stopping, "
            "relaxing/raising errors, or revisiting the starting model."
        )
    else:
        status = "WATCH"
        message = "RMS is still improving; continue monitoring before selecting a model."

    if roughening and not target_reached:
        status = "WARN"
        message += " Roughness is still growing, so avoid accepting extra structure blindly."

    return {
        "backend": str(best_row["backend"]),
        "status": status,
        "best_iteration": int(best_row["iteration"]),
        "best_rms": float(best_row["rms"]),
        "final_rms": float(finite["rms"].iloc[-1]),
        "target_reached": target_reached,
        "late_rms_drop": late_drop,
        "message": message,
    }


diagnostics = pd.DataFrame(
    [
        diagnose_backend(group, target_rms)
        for _, group in history.groupby("backend", sort=False)
    ]
)
diagnostics_file = run_root / "convergence_diagnostics.csv"
diagnostics.to_csv(diagnostics_file, index=False)

print("Convergence diagnostics:")
print(diagnostics.to_string(index=False))
Convergence diagnostics:
backend status  best_iteration  best_rms  final_rms  target_reached  late_rms_drop                                                                                                                                       message
Occam2D   WARN              18     1.057      1.057           False       0.031129 RMS is still improving; continue monitoring before selecting a model. Roughness is still growing, so avoid accepting extra structure blindly.
  ModEM  WATCH              18     1.087      1.087           False       0.035000                                                                         RMS is still improving; continue monitoring before selecting a model.

6. Plot a convergence dashboard#

The top panel answers “is the data misfit still decreasing?” The lower panels answer “what did we pay for that decrease?” In smooth inversions, a very small RMS decrease accompanied by a large roughness increase is often a sign that the next model is less interpretable, even if the number looks slightly better.

fig, axes = plt.subplots(2, 2, figsize=(12.5, 8.5), constrained_layout=True)
ax_rms, ax_drop, ax_occam, ax_text = axes.ravel()

for backend, frame in history.groupby("backend", sort=False):
    ax_rms.plot(
        frame["iteration"],
        frame["rms"],
        marker="o",
        linewidth=2,
        label=backend,
    )
    best = frame.loc[frame["rms"].idxmin()]
    ax_rms.scatter(
        best["iteration"],
        best["rms"],
        s=90,
        zorder=4,
        edgecolor="black",
        label=f"{backend} best",
    )

ax_rms.axhline(target_rms, color="black", linestyle="--", linewidth=1.2)
ax_rms.text(
    0.02,
    target_rms + 0.03,
    f"target RMS {target_rms:.2f}",
    transform=ax_rms.get_yaxis_transform(),
    fontsize=9,
)
ax_rms.set_xlabel("Iteration")
ax_rms.set_ylabel("Normalized RMS")
ax_rms.set_title("RMS convergence")
ax_rms.grid(True, alpha=0.25)
ax_rms.legend(fontsize=8)

for backend, frame in history.groupby("backend", sort=False):
    ordered = frame.sort_values("iteration")
    improvement = -ordered["rms"].diff()
    ax_drop.bar(
        ordered["iteration"] + (-0.18 if backend == "Occam2D" else 0.18),
        improvement,
        width=0.32,
        alpha=0.75,
        label=backend,
    )
ax_drop.axhline(0.02, color="black", linestyle=":", linewidth=1.0)
ax_drop.set_xlabel("Iteration")
ax_drop.set_ylabel("RMS improvement from previous iteration")
ax_drop.set_title("Late bars near zero indicate stagnation")
ax_drop.grid(True, axis="y", alpha=0.25)
ax_drop.legend(fontsize=8)

occam_plot = occam_history.sort_values("iteration")
ax_occam.plot(
    occam_plot["iteration"],
    occam_plot["roughness"],
    color="tab:orange",
    marker="s",
    label="roughness",
)
ax_occam.set_xlabel("Occam iteration")
ax_occam.set_ylabel("Model roughness", color="tab:orange")
ax_occam.tick_params(axis="y", labelcolor="tab:orange")
ax_occam.grid(True, alpha=0.25)
ax_occam.set_title("Occam trade-off: fit versus structure")

ax_mu = ax_occam.twinx()
ax_mu.plot(
    occam_plot["iteration"],
    occam_plot["lagrange"],
    color="tab:purple",
    marker="^",
    linestyle="--",
    label="Lagrange multiplier",
)
ax_mu.set_ylabel("Lagrange multiplier", color="tab:purple")
ax_mu.tick_params(axis="y", labelcolor="tab:purple")

summary_lines = [
    "Monitoring decision",
    "-------------------",
]
for row in diagnostics.itertuples(index=False):
    summary_lines.extend(
        [
            f"{row.backend}: {row.status}",
            f"  best iteration: {row.best_iteration}",
            f"  best RMS: {row.best_rms:.3f}",
            f"  final RMS: {row.final_rms:.3f}",
            f"  late RMS drop: {row.late_rms_drop:.3f}",
            "",
        ]
    )
summary_lines.extend(
    [
        "If final RMS is slightly above target but",
        "the curve is flat, look at residual maps,",
        "site-level misfit, and error floors before",
        "forcing more structure into the model.",
    ]
)

ax_text.axis("off")
ax_text.text(
    0.02,
    0.98,
    "\n".join(summary_lines),
    va="top",
    family="monospace",
    fontsize=9.5,
)

fig.suptitle("Inversion convergence monitor", fontsize=15)
dashboard_file = figure_dir / "inversion_convergence_monitor.png"
fig.savefig(dashboard_file, dpi=180)
plt.show()
Inversion convergence monitor, RMS convergence, Late bars near zero indicate stagnation, Occam trade-off: fit versus structure

7. Write a small monitoring report#

The report is intentionally machine-readable. In a real project it can be attached to a run archive, compared between solver attempts, or used by CI to flag suspicious run histories.

report = {
    "target_rms": target_rms,
    "error_policy": {
        "rho_error_floor_percent": policy.get("rho_error_floor_percent"),
        "phase_error_floor_deg": policy.get("phase_error_floor_deg"),
        "impedance_relative_floor": policy.get("impedance_relative_floor"),
    },
    "inputs": {
        "occam_log": str(occam_log_file),
        "modem_history": str(modem_history_file),
        "run_folder_audit": str(audit_file),
    },
    "outputs": {
        "normalized_history": str(history_file),
        "diagnostics": str(diagnostics_file),
        "dashboard": str(dashboard_file),
    },
    "diagnostics": diagnostics.to_dict(orient="records"),
}

report_file = run_root / "convergence_monitor_report.json"
report_file.write_text(json.dumps(report, indent=2), encoding="utf-8")

print(f"Normalized history: {history_file}")
print(f"Diagnostics table: {diagnostics_file}")
print(f"Monitoring report: {report_file}")
print(f"Dashboard figure: {dashboard_file}")
Normalized history: /opt/build/repo/docs/examples/inversion/workspaces/l18_prepared_workspace/04_run_files/convergence_history_normalized.csv
Diagnostics table: /opt/build/repo/docs/examples/inversion/workspaces/l18_prepared_workspace/04_run_files/convergence_diagnostics.csv
Monitoring report: /opt/build/repo/docs/examples/inversion/workspaces/l18_prepared_workspace/04_run_files/convergence_monitor_report.json
Dashboard figure: /opt/build/repo/docs/examples/inversion/workspaces/l18_prepared_workspace/05_figures/inversion_convergence_monitor.png

8. How to use this with real solver output#

For a real Occam2D run, replace the demonstration log path with the true solver log:

occam_log = OccamLog.read(occam_dir / "LogFile.logfile")

For ModEM or another backend, normalize its text output to a CSV with at least iteration and rms columns. Additional columns such as objective function, model norm, gradient norm, accepted step length, or wall time make the diagnostic much more useful.

A model is usually ready for interpretation when these conditions agree:

  • RMS has reached the planned target, or the last iterations are clearly flat;

  • residuals are spatially balanced rather than concentrated at a few sites;

  • roughness/model norm has not exploded for tiny RMS gains;

  • the selected iteration is documented, not just “the last file in the folder”.

The most useful next gallery step is to load the selected response/model and map where misfit remains high.

Total running time of the script: (0 minutes 0.843 seconds)

Gallery generated by Sphinx-Gallery