Source code for pycsamt.emtools.lcurve

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

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


def _as_1d(a: Any) -> np.ndarray:
    v = np.asarray(a, dtype=float).ravel()
    m = np.isfinite(v) & (v > 0.0)
    return v[m]


def _prep_curve(
    misfit: Any,
    rough: Any,
    lam: Any | None,
    *,
    sort: str = "auto",  # auto|x|lambda
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    x = _as_1d(rough)
    y = _as_1d(misfit)
    n = int(min(x.size, y.size))
    x, y = x[:n], y[:n]
    if lam is None:
        l = np.arange(n, dtype=float)
    else:
        l = np.asarray(lam, dtype=float).ravel()[:n]
    if sort == "x":
        idx = np.argsort(x)
    elif sort == "lambda":
        idx = np.argsort(l)
    else:
        mono = np.all(np.diff(l) >= 0) or np.all(np.diff(l) <= 0)
        idx = np.argsort(l) if mono else np.argsort(x)
    return x[idx], y[idx], l[idx]


def _movavg(v: np.ndarray, w: int) -> np.ndarray:
    w = int(max(1, w))
    if v.size == 0 or w == 1:
        return v
    k = np.ones(w, dtype=float) / float(w)
    return np.convolve(v, k, mode="same")


def _curvature_kappa(
    x: np.ndarray,
    y: np.ndarray,
    *,
    smooth: int = 3,
) -> np.ndarray:
    lx = np.log10(np.maximum(x, 1e-300))
    ly = np.log10(np.maximum(y, 1e-300))
    if smooth > 1:
        lx = _movavg(lx, smooth)
        ly = _movavg(ly, smooth)
    t = np.arange(lx.size, dtype=float)
    x1 = np.gradient(lx, t, edge_order=2)
    y1 = np.gradient(ly, t, edge_order=2)
    x2 = np.gradient(x1, t, edge_order=2)
    y2 = np.gradient(y1, t, edge_order=2)
    den = (x1 * x1 + y1 * y1 + 1e-24) ** 1.5
    k = np.abs(x1 * y2 - y1 * x2) / den
    return k


def _maxdist_knee(
    x: np.ndarray,
    y: np.ndarray,
) -> np.ndarray:
    lx = np.log10(np.maximum(x, 1e-300))
    ly = np.log10(np.maximum(y, 1e-300))
    p0 = np.array([lx[0], ly[0]], dtype=float)
    p1 = np.array([lx[-1], ly[-1]], dtype=float)
    v = p1 - p0
    den = np.hypot(v[0], v[1]) + 1e-24
    # signed distance to end-point line
    d = np.abs(v[1] * (lx - p0[0]) - v[0] * (ly - p0[1])) / den
    return d


def _pick_corner(
    x: np.ndarray,
    y: np.ndarray,
    *,
    method: str = "curvature",  # curvature|maxdist
    smooth: int = 3,
    skip: int = 1,
) -> tuple[int, np.ndarray]:
    n = int(min(x.size, y.size))
    if n <= 2:
        return 0, np.zeros(n)
    if method == "maxdist":
        s = _maxdist_knee(x, y)
    else:
        s = _curvature_kappa(x, y, smooth=smooth)
    lo = int(max(0, skip))
    hi = int(max(lo + 1, n - skip))
    j = int(lo + np.nanargmax(s[lo:hi]))
    return j, s


[docs] def lcurve_table( misfit: Any, rough: Any, lam: Any | None = None, *, sort: str = "auto", method: str = "curvature", smooth: int = 3, skip: int = 1, return_dict: bool = False, ): x, y, l = _prep_curve(misfit, rough, lam, sort=sort) if x.size == 0: return pd.DataFrame() if pd is not None else None j, s = _pick_corner(x, y, method=method, smooth=smooth, skip=skip) lx = np.log10(x) ly = np.log10(y) # local slope d log(misfit) / d log(rough) sl = np.gradient(ly) / (np.gradient(lx) + 1e-24) if return_dict: return dict(rough=x, misfit=y, lam=l, curv=s, slope=sl, corner=j) df = pd.DataFrame(dict(rough=x, misfit=y, lam=l, curv=s, slope=sl)) df.attrs["corner_idx"] = j return df
# ------------------------------- plotting ------------------------------- #
[docs] def plot_lcurve( misfit: Any | Sequence[Any], rough: Any | Sequence[Any], lam: Any | Sequence[Any] | None = None, *, labels: Sequence[str] | None = None, colors: Sequence[str] | None = None, cmap: str = "viridis", marker: str = "o", ms: float = 3.0, lw: float = 1.4, alpha: float = 0.9, show_points: bool = True, show_path: bool = True, arrow_every: int = 0, # 0 disables method: str = "curvature", smooth: int = 3, skip: int = 1, show_corner: bool = True, corner_style: dict[str, Any] | None = None, show_inset: bool = True, inset_loc: tuple[float, float, float, float] = (0.62, 0.12, 0.32, 0.32), figsize: tuple[float, float] = (6.0, 4.6), ax: plt.Axes | None = None, ): # normalize to list of curves if isinstance(misfit, (list, tuple)): Ms = list(misfit) Rs = list(rough) # type: ignore Ls = list(lam) if isinstance(lam, (list, tuple)) else [None] * len(Ms) labs = ( list(labels) if labels is not None else [f"C{i}" for i in range(len(Ms))] ) else: Ms = [misfit] Rs = [rough] Ls = [lam] labs = [labels[0]] if labels else ["curve"] if colors is None: cols = [None] * len(Ms) else: cols = list(colors) # main axes if ax is None: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.figure # store corners for legend text leg = [] # Shared inset axes: created once and reused across curves, rather # than once per curve at the identical `inset_loc` (which used to # silently stack N insets on top of each other, leaving only the # last curve's score visible). axins = None for i, (m, r, la) in enumerate(zip(Ms, Rs, Ls)): x, y, l = _prep_curve(m, r, la, sort="auto") if x.size == 0: continue j, score = _pick_corner(x, y, method=method, smooth=smooth, skip=skip) c = cols[i] if c is None: c = plt.get_cmap(cmap)(0.15 + 0.65 * i / max(1, len(Ms) - 1)) # path and points ax.set_xscale("log") ax.set_yscale("log") if show_path: ax.plot(x, y, "-", color=c, lw=lw, alpha=alpha) if show_points: ax.scatter( x, y, c=np.linspace(0, 1, x.size), s=12 * ms, cmap=cmap, edgecolors="none", alpha=0.85, ) # optional arrows showing λ direction if arrow_every and x.size > 2: step = int(max(1, arrow_every)) for k in range(0, x.size - step, step): ax.annotate( "", xy=(x[k + step], y[k + step]), xytext=(x[k], y[k]), arrowprops=dict( arrowstyle="->", lw=0.8, color=c, shrinkA=0, shrinkB=0 ), ) # corner highlight if show_corner: sty = dict(marker="*", ms=9, mec="k", mfc=c, mew=0.8) if corner_style: sty.update(corner_style) ax.plot([x[j]], [y[j]], **sty) leg.append(f"{labs[i]} λ*≈{l[j]:.3g}") else: leg.append(labs[i]) # inset curvature / distance -- one shared inset for all curves if show_inset: if axins is None: axins = fig.add_axes(inset_loc) axins.set_xticks([]) axins.set_yticks([]) axins.set_title( "curv" if method == "curvature" else "knee", fontsize=8, ) idx = np.arange(score.size) axins.plot(idx, score, "-", color=c, lw=1.0) axins.axvline(j, color=c, ls="--", lw=1.0) ax.grid(True, alpha=0.25, which="both") ax.set_xlabel("||Lm|| (model roughness)") ax.set_ylabel("||Gm−d|| (data misfit)") if leg: ax.legend(leg, loc="best", frameon=False, fontsize=9) return ax