L-curve regularization-parameter selection (pycsamt.emtools.lcurve)#

pycsamt.emtools.lcurve is the one emtools module that is not about EDI data at all: it is a generic Tikhonov-regularization diagnostic that takes any (misfit, roughness, \(\lambda\)) triple — from a 1-D sounding inversion, a 2-D/3-D model, or any other regularized least-squares problem — and finds the “corner” of the L-shaped trade-off curve between fitting the data and keeping the model simple.

Because it needs a real regularization sweep rather than EDI values directly, this example builds one honestly from real data: a small, genuine 1-D Tikhonov smoothing problem — recover a smooth model from noisy real apparent resistivity — solved in closed form at 60 \(\lambda\) values for stations from L18PLT (data/AMT/WILLY_DATA/). The resulting misfit/roughness pairs are real numbers from an actual regularized solve, not fabricated to look like a textbook L-curve.

1. The concept, with a synthetic curve#

lcurve_table() is the module’s core computation: given misfit and roughness arrays, it sorts them, scores every point for “cornerness”, and reports the corner index. A hand-built curve with an obvious knee makes the mechanics concrete before any real numbers are involved.

import matplotlib.pyplot as plt
import numpy as np

from pycsamt.emtools import lcurve_table, plot_lcurve

lam_demo = np.logspace(-3, 3, 30)
rough_demo = 1.0 / (1.0 + lam_demo**2)  # decreases with lambda
misfit_demo = lam_demo**2 / (1.0 + lam_demo**2)  # increases with lambda

table_demo = lcurve_table(misfit_demo, rough_demo, lam_demo)
j_demo = table_demo.attrs["corner_idx"]
print(
    f"synthetic corner: lambda*={table_demo['lam'].iloc[j_demo]:.3g}, "
    f"index {j_demo} of {len(table_demo)}"
)

plot_lcurve(misfit_demo, rough_demo, lam_demo)
curv
synthetic corner: lambda*=1.27, index 15 of 30

<Axes: xlabel='||Lm|| (model roughness)', ylabel='||Gm−d|| (data misfit)'>

Reading this figure. The classic L shape: a near-vertical branch at small \(\lambda\) (misfit barely changes, roughness drops fast) and a near-horizontal branch at large \(\lambda\) (the opposite) meeting at a corner — marked with a star — which plot_lcurve() locates automatically. The small inset shows the underlying curvature score peaking exactly at that point.

2. A real L-curve from real data#

The rest of this example uses a genuine (if small) Tikhonov smoothing problem: given one station’s real, noisy \(\log_{10}\rho_a(T)\) curve as data \(d\), solve \((I + \lambda^2 D^\top D)\,m = d\) for a smoothed model \(m\), where \(D\) is the second-difference (roughness) operator. Misfit is \(\Vert m-d \Vert\) and roughness is \(\Vert Dm \Vert\) — the same quantities any regularized inversion reports, computed here in closed form.

from _datasets import load_survey  # noqa: E402

from pycsamt.emtools import ensure_sites  # noqa: E402
from pycsamt.emtools._core import (  # noqa: E402
    _get_z_block,
    _iter_items,
    _name,
)

survey = ensure_sites(load_survey("amt_l18plt"), recursive=False)


def _rough_operator(n: int) -> np.ndarray:
    D = np.zeros((n - 2, n))
    for i in range(n - 2):
        D[i, i], D[i, i + 1], D[i, i + 2] = 1.0, -2.0, 1.0
    return D


def tikhonov_sweep(log10_rho: np.ndarray, lambdas: np.ndarray):
    n = log10_rho.size
    D = _rough_operator(n)
    I = np.eye(n)
    misfits, roughs, models = [], [], []
    for lam in lambdas:
        m = np.linalg.solve(I + lam**2 * (D.T @ D), log10_rho)
        misfits.append(np.linalg.norm(m - log10_rho))
        roughs.append(np.linalg.norm(D @ m))
        models.append(m)
    return np.array(misfits), np.array(roughs), np.array(models)


def station_log10_rho(survey, name: str):
    for i, ed in enumerate(_iter_items(survey)):
        if _name(ed, i) == name:
            _, z, fr = _get_z_block(ed)
            rho = 0.2 * np.abs(z[:, 0, 1]) ** 2 / fr
            return np.log10(rho), fr
    raise KeyError(name)


lambdas = np.logspace(-3, 3, 60)
d_18001a, fr_18001a = station_log10_rho(survey, "18-001A")
mi_18001a, ro_18001a, models_18001a = tikhonov_sweep(d_18001a, lambdas)

table = lcurve_table(mi_18001a, ro_18001a, lambdas)
j = table.attrs["corner_idx"]
lam_star = table["lam"].iloc[j]
print(f"18-001A: lambda* = {lam_star:.3g}")

plot_lcurve(mi_18001a, ro_18001a, lambdas, labels=["18-001A"])
curv
18-001A: lambda* = 0.349

<Axes: xlabel='||Lm|| (model roughness)', ylabel='||Gm−d|| (data misfit)'>

Reading this figure. Real data produces the same qualitative L shape as the synthetic curve in section 1, just noisier — the corner lands at \(\lambda^* \approx 0.35\), comfortably inside the swept range rather than at either end, a first sign that the sweep actually bracketed the useful region.

3. Why the corner matters: three regularization levels#

Plotting the recovered model itself at \(\lambda^*\) against a far smaller (under-regularized) and far larger (over-regularized) choice shows what the corner is actually protecting against.

per_18001a = 1.0 / fr_18001a
j_small = 5  # lambda far below the corner
j_large = 50  # lambda far above the corner

fig, ax = plt.subplots(figsize=(7, 4.5))
ax.semilogx(
    per_18001a, d_18001a, "o", ms=4, color="0.6", label="observed (noisy)"
)
ax.plot(
    per_18001a,
    models_18001a[j_small],
    "-",
    color="#d62728",
    label=f"lambda={lambdas[j_small]:.3g} (under-regularized)",
)
ax.plot(
    per_18001a,
    models_18001a[j],
    "-",
    color="#2ca02c",
    lw=2.2,
    label=f"lambda*={lam_star:.3g} (corner)",
)
ax.plot(
    per_18001a,
    models_18001a[j_large],
    "-",
    color="#1f77b4",
    label=f"lambda={lambdas[j_large]:.3g} (over-regularized)",
)
ax.set_xlabel("Period (s)")
ax.set_ylabel(r"$\log_{10}\rho_a$")
ax.legend(fontsize=7)
ax.set_title("18-001A — effect of the regularization level")
18-001A — effect of the regularization level
Text(0.5, 1.0, '18-001A — effect of the regularization level')

Reading this figure. The under-regularized model (red) tracks every wiggle in the noisy data — including noise that is almost certainly not real structure. The over-regularized model (blue) is nearly a flat line, discarding real curvature along with the noise. The corner model (green) sits between the two: smoother than the raw data but still following its genuine large-scale shape. This is the practical payoff of the L-curve — picking a defensible middle ground without eyeballing it.

4. Comparing several stations at once#

plot_lcurve() accepts a list of curves and shares one inset showing every curve’s corner score together.

names = ["18-001A", "18-016A", "18-007U"]
sweeps = {}
for n in names:
    d_n, _ = station_log10_rho(survey, n)
    sweeps[n] = tikhonov_sweep(d_n, lambdas)

plot_lcurve(
    [sweeps[n][0] for n in names],
    [sweeps[n][1] for n in names],
    [lambdas] * len(names),
    labels=names,
)

for n in names:
    t = lcurve_table(sweeps[n][0], sweeps[n][1], lambdas)
    print(f"{n}: lambda* = {t['lam'].iloc[t.attrs['corner_idx']]:.3g}")
curv
18-001A: lambda* = 0.349
18-016A: lambda* = 0.349
18-007U: lambda* = 0.349

Reading this figure. 18-016A and 18-007U are the same two stations singled out in the anisotropy/impedance examples for their unusually strong ratio anisotropy and Swift skew, respectively. Their L-curves sit at different absolute misfit/roughness levels — 18-001A’s roughness runs highest at every \(\lambda\) (\(\Vert Lm\Vert\approx 3.57\) at \(\lambda=10^{-3}\)), 18-016A sits in the middle (\(\approx 1.72\)) and 18-007U lowest (\(\approx 1.41\)), consistent with 18-001A being the more structured, less smooth sounding — yet all three corners land on exactly the same grid index (25 of 60, \(\lambda^*\approx 0.349\)) despite the very different absolute resistivity levels behind them. The right relative amount of smoothing for this survey’s noise character turns out to be remarkably consistent station to station, even for the two flagged elsewhere as unusual.

5. Advanced: is the corner pick robust to how it is found?#

method offers "curvature" (numerical second-derivative curvature of the log-log curve) and "maxdist" (perpendicular distance from the line joining the curve’s two endpoints), and smooth controls how much the curve is smoothed before differentiating for the curvature method specifically.

for sm in (1, 3, 7):
    t_curv = lcurve_table(
        mi_18001a, ro_18001a, lambdas, method="curvature", smooth=sm
    )
    t_dist = lcurve_table(
        mi_18001a, ro_18001a, lambdas, method="maxdist", smooth=sm
    )
    lam_curv = t_curv["lam"].iloc[t_curv.attrs["corner_idx"]]
    lam_dist = t_dist["lam"].iloc[t_dist.attrs["corner_idx"]]
    print(
        f"smooth={sm}: curvature lambda*={lam_curv:.4g}   maxdist lambda*={lam_dist:.4g}"
    )

fig, (axc, axd) = plt.subplots(1, 2, figsize=(11, 4.5), sharey=True)
plot_lcurve(
    mi_18001a, ro_18001a, lambdas, method="curvature", smooth=7, ax=axc
)
axc.set_title("method='curvature', smooth=7")
plot_lcurve(mi_18001a, ro_18001a, lambdas, method="maxdist", smooth=7, ax=axd)
axd.set_title("method='maxdist', smooth=7")
method='curvature', smooth=7, method='maxdist', smooth=7, curv, knee
smooth=1: curvature lambda*=0.3486   maxdist lambda*=0.2759
smooth=3: curvature lambda*=0.3486   maxdist lambda*=0.2759
smooth=7: curvature lambda*=495.4   maxdist lambda*=0.2759

Text(0.5, 1.0, "method='maxdist', smooth=7")

Reading this output/figure. At light smoothing (1 or 3), both methods agree closely (\(\lambda^*\approx 0.35\) vs. 0.28). At heavy smoothing (smooth=7), the curvature method’s pick jumps to \(\lambda^*\approx 495\) — essentially the far edge of the sweep, visibly the wrong corner in the left panel — while maxdist barely moves, since it never differentiates the curve at all. Heavy smoothing can silently break the curvature method; maxdist is the more robust default when in doubt.

6. Advanced: showing the lambda direction along the curve#

arrow_every draws direction arrows along the path, which is a useful reminder that a physical parameter sweep — not just point density — is what “moves” you along an L-curve.

plot_lcurve(mi_18001a, ro_18001a, lambdas, arrow_every=6, show_points=False)
curv
<Axes: xlabel='||Lm|| (model roughness)', ylabel='||Gm−d|| (data misfit)'>

Reading this figure. Arrows point from small to large \(\lambda\). Near the start of the sweep the arrows are almost vertical: roughness barely moves while misfit climbs by orders of magnitude (the model is already close to the data, so a little smoothing costs a lot of fit). Past the corner the arrows turn almost horizontal: roughness keeps falling by decades while misfit grows only slowly — the “cheap” smoothing the corner is meant to capture before this expensive-fit regime takes over. Same two regimes as section 3, now visible directly as a direction of travel along one curve rather than three separate models.

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

Gallery generated by Sphinx-Gallery