Skip to content

spectrafit vs. lmfit: a complex, 8-peak spectrum

Synthetic example

The 8-peak XPS-style spectrum below is built from local numpy formulas with known truth parameters — chosen so both backends' fits can be checked against a known answer, not a sweep proving spectrafit/lmfit agreement across arbitrary real spectra.

Quick example

An XPS-style, 8-peak, three-lineshape spectrum with a tied spin-orbit doublet linewidth, fit independently by spectrafit and by a hand-built lmfit composite — the genuinely harder sibling of spectrafit_vs_lmfit_moderate.md.

Peak formulas shared by both backends (spectrafit's own convention: amplitude is the peak height at center, never an integrated area):

# Peak formulas -- identical to spectrafit-core's own convention
# (docs/reference/models/index.md / python/oracles/models.py): ``amplitude``
# is the peak HEIGHT at ``center``
# (never an integrated area), and ``sigma`` is the Gaussian standard deviation
# / Lorentzian HWHM (never a FWHM). Defined locally rather than imported from
# ``python/oracles/models.py`` -- that module is internal benchmark-harness
# code, not a dependency of this public gallery -- so this script stays
# trivially runnable AND the lmfit oracle stays genuinely independent of
# spectrafit-core's own implementation.


def gaussian(x, amplitude, center, sigma):
    """Gaussian peak: ``amplitude`` at ``center``, std-dev ``sigma``."""
    return amplitude * np.exp(-0.5 * ((x - center) / sigma) ** 2)


def lorentzian(x, amplitude, center, sigma):
    """Lorentzian peak normalized to ``amplitude`` at ``center`` (HWHM ``sigma``)."""
    return amplitude / (1.0 + ((x - center) / sigma) ** 2)


def pseudo_voigt(x, amplitude, center, sigma, fraction):
    """Pseudo-Voigt: ``fraction``*Lorentzian + (1-``fraction``)*Gaussian, peak ``amplitude``."""
    mix = float(np.clip(fraction, 0.0, 1.0))
    z = (x - center) / sigma
    lorentz = amplitude / (1.0 + z**2)
    gauss = amplitude * np.exp(-0.5 * z**2)
    return mix * lorentz + (1.0 - mix) * gauss


def linear(x, slope, intercept):
    """Linear background ``slope``*x + ``intercept``."""
    return slope * x + intercept


PEAK_FN = {"gaussian": gaussian, "lorentzian": lorentzian, "pseudo_voigt": pseudo_voigt}
MODEL_TYPE = {
    "gaussian": ModelType.GAUSSIAN,
    "lorentzian": ModelType.LORENTZIAN,
    "pseudo_voigt": ModelType.PSEUDO_VOIGT,
}

The same three local formulas back both backends' synthetic data below — no built-in lmfit or spectrafit convenience function is used to generate it, so both fits start from an identical, apples-to-apples ground truth.

def synthesize_data() -> tuple[np.ndarray, np.ndarray]:
    """Synthesize the 8-peak + linear-background spectrum (with noise).

    Sums every ``TRUE`` component plus the linear background, then adds
    Gaussian measurement noise (``sigma=0.06``, seeded for reproducibility).
    """
    rng = np.random.default_rng(11)
    x = np.linspace(280.0, 296.0, 480)
    y_true = np.zeros_like(x)
    for pid in PEAK_ORDER:
        kind, true_params = TRUE[pid]
        y_true = y_true + PEAK_FN[kind](x, **true_params)
    y_true = y_true + linear(x, **BG_TRUE)
    noise = rng.normal(0, 0.06, len(x))
    return x, y_true + noise


x, y = synthesize_data()

p3 and p4 are the two lines of one spin-orbit doublet, and physically they must share the same natural linewidth — that's what the ExprEdge tying p4.sigma to p3.sigma below is actually encoding, not just a syntax demo of the mechanism from shared_params.md.

def build_graph() -> FitGraph:
    """Fresh FitGraph: 8 peaks (3 lineshape families) + linear bg + 1 ExprEdge tie."""
    nodes = []
    for pid in PEAK_ORDER:
        kind, _ = TRUE[pid]
        guess = GUESS[pid]
        parameters = {
            "amplitude": Parameter(value=guess["amplitude"], min=0.0),
            "center": Parameter(value=guess["center"]),
            "sigma": Parameter(value=guess["sigma"], min=1e-3),
        }
        if kind == "pseudo_voigt":
            parameters["fraction"] = Parameter(
                value=guess["fraction"],
                min=0.0,
                max=1.0,
            )
        nodes.append(
            ModelNodeSpec(id=pid, model_type=MODEL_TYPE[kind], parameters=parameters),
        )
    nodes.append(
        ModelNodeSpec(
            id="bg",
            model_type=ModelType.LINEAR,
            parameters={
                "slope": Parameter(value=BG_GUESS["slope"]),
                "intercept": Parameter(value=BG_GUESS["intercept"]),
            },
        ),
    )
    return FitGraph(
        nodes=nodes,
        expr_edges=[
            # p4 (the 2p1/2-like doublet partner) shares p3's natural linewidth --
            # the same tie pattern as shared_params.py, applied for a physical
            # (not merely illustrative) reason.
            ExprEdge(target_node="p4", target_param="sigma", expression="p3.sigma"),
        ],
    )

As in the moderate example, lmfit's built-in peak models normalize by area rather than height, so the composite here is built by hand from the same formulas above, with the tie re-expressed as an lmfit parameter expression via the exact dotted-to-underscore translation the real oracle backend uses.

def build_lmfit_composite() -> tuple[lmfit.Model, lmfit.Parameters]:
    """Hand-built lmfit composite mirroring python/oracles/backends/_lmfit.py.

    Same loop structure as ``LmfitBackend.build``: one ``lmfit.Model`` per
    node (prefixed with the node id, which already matches ``_lmfit.py``'s
    ``f"p{i}_"`` convention here since the graph's own node ids are
    ``p0``..``p7``), summed with ``+``, ``.make_params(**guess)`` per node,
    then the tie applied via the same dotted-to-underscore regex translation
    ``_lmfit.py`` uses for ``expr_edges`` (its lines ~91-103).
    """
    composite: lmfit.Model | None = None
    params: lmfit.Parameters | None = None
    for pid in PEAK_ORDER:
        kind, _ = TRUE[pid]
        m = lmfit.Model(PEAK_FN[kind], prefix=f"{pid}_")
        composite = m if composite is None else composite + m
        pars = m.make_params(**GUESS[pid])
        pars[f"{pid}_amplitude"].set(min=0.0)
        pars[f"{pid}_sigma"].set(min=1e-3)
        if kind == "pseudo_voigt":
            pars[f"{pid}_fraction"].set(min=0.0, max=1.0)
        params = pars if params is None else params.update(pars) or params

    # PEAK_ORDER is non-empty, so the loop above always ran at least once --
    # both are genuinely never None here. `X.update(Y) or X` is runtime-safe
    # (dict.update always returns None, so `or` always falls through to the
    # just-mutated X) but a type checker can't verify that from the pattern
    # alone; asserting is the actual narrowing point, not a defensive check
    # against a real possibility.
    assert composite is not None
    assert params is not None

    bg_model = lmfit.Model(linear, prefix="bg_")
    composite = composite + bg_model
    bg_pars = bg_model.make_params(**BG_GUESS)
    params.update(bg_pars)

    # Apply the ExprEdge as an lmfit parameter expression, translating
    # spectrafit's dotted "node.param" syntax into lmfit's "node_param"
    # syntax -- the exact regex `_lmfit.py` applies to every expr_edge.
    expr_edge = {"target_node": "p4", "target_param": "sigma", "expression": "p3.sigma"}
    lmfit_target = f"{expr_edge['target_node']}_{expr_edge['target_param']}"
    lmfit_expr = re.sub(r"\b(p\d+)\.(\w+)\b", r"\1_\2", expr_edge["expression"])
    params[lmfit_target].set(expr=lmfit_expr)

    return composite, params

With both backends fit from the same starting guess, the report below compares chi2, iteration/evaluation counts, wall time, and all 27 fitted parameters between the two independent solvers.

def run_and_report(
    x: np.ndarray,
    y: np.ndarray,
    n_reps: int = 5,
) -> tuple[FitResult, ModelResult, float, float, float, float]:
    """Fit both backends ``n_reps`` times, print the side-by-side report.

    Returns ``(sf_result, lm_result, sf_median_time, lm_median_time, lm_r2,
    max_delta)`` -- everything the tie-verification step and the
    ``__main__`` plotting block need, so neither has to re-run either
    backend or recompute the per-parameter comparison.
    """
    data = MeasurementData(x=x.tolist(), y=y.tolist())

    sf_times: list[float] = []
    sf_result = fit(build_graph(), data)  # warm-up, untimed
    for _ in range(n_reps):
        start = time.perf_counter()
        sf_result = fit(build_graph(), data)
        sf_times.append(time.perf_counter() - start)
    sf_median_time = sorted(sf_times)[len(sf_times) // 2]

    lm_times: list[float] = []
    composite, params = build_lmfit_composite()
    lm_result = composite.fit(y, params, x=x)  # warm-up, untimed
    for _ in range(n_reps):
        _, fresh_params = build_lmfit_composite()
        start = time.perf_counter()
        lm_result = composite.fit(y, fresh_params, x=x)
        lm_times.append(time.perf_counter() - start)
    lm_median_time = sorted(lm_times)[len(lm_times) // 2]

    lm_best_fit = np.asarray(lm_result.best_fit, dtype=float)
    lm_chi2 = float(np.sum((y - lm_best_fit) ** 2))
    lm_n_iter = int(getattr(lm_result, "nfev", 0) or 0)

    print(f"{'':30s} {'spectrafit':>25s} {'lmfit':>25s}")
    print(f"{'success':30s} {sf_result.success!s:>25s} {lm_result.success!s:>25s}")
    print(f"{'chi2':30s} {sf_result.chi2:25.6f} {lm_chi2:25.6f}")
    print(
        f"{'n_iter (spectrafit) / nfev (lmfit)':30s} {sf_result.n_iter:25d} {lm_n_iter:25d}",
    )
    print(
        f"{'median wall time (ms)':30s} {sf_median_time * 1e3:25.3f} {lm_median_time * 1e3:25.3f}",
    )
    print()

    print(f"{'parameter':34s} {'spectrafit':>12s} {'lmfit':>12s} {'|delta|':>12s}")
    max_delta = 0.0
    for pid in PEAK_ORDER:
        kind, _ = TRUE[pid]
        names = list(
            PEAK_FN[kind].__code__.co_varnames[1 : PEAK_FN[kind].__code__.co_argcount],
        )
        for pname in names:
            sf_val = sf_result.parameters[f"{pid}.{pname}"].value
            lm_val = float(lm_result.params[f"{pid}_{pname}"].value)
            delta = abs(sf_val - lm_val)
            max_delta = max(max_delta, delta)
            label = f"{pid}.{pname} ({LABELS[pid]})"
            print(f"{label:34s} {sf_val:12.4f} {lm_val:12.4f} {delta:12.2e}")
    for bname in ("slope", "intercept"):
        sf_val = sf_result.parameters[f"bg.{bname}"].value
        lm_val = float(lm_result.params[f"bg_{bname}"].value)
        delta = abs(sf_val - lm_val)
        max_delta = max(max_delta, delta)
        print(f"{'bg.' + bname:34s} {sf_val:12.4f} {lm_val:12.4f} {delta:12.2e}")
    print()
    print(
        f"Largest single-parameter disagreement (spectrafit vs. lmfit): {max_delta:.4f}",
    )

    lm_ss_res = float(np.sum((y - lm_best_fit) ** 2))
    lm_ss_tot = float(np.sum((y - np.mean(y)) ** 2))
    lm_r2 = 1.0 - lm_ss_res / lm_ss_tot

    return sf_result, lm_result, sf_median_time, lm_median_time, lm_r2, max_delta


sf_result, lm_result, sf_median_time, lm_median_time, lm_r2, max_delta = run_and_report(
    x,
    y,
)

Printed numbers alone can't distinguish "close because both converged correctly" from "close by coincidence in a shallow, near-degenerate cost surface" — so both the tie itself and the aggregate agreement are checked in code, with a tolerance shaped by what this harder, overlapping problem can actually guarantee.

def verify_tie_and_assertions(
    sf_result: FitResult,
    lm_result: ModelResult,
    lm_r2: float,
) -> float:
    """Assert both fits succeeded and the ``p4.sigma = p3.sigma`` tie holds exactly.

    A loose ``R2`` tolerance (``< 0.02``), deliberately -- see the module
    docstring's honesty note: 8 overlapping peaks with one tied linewidth is
    a harder, potentially locally-non-unique landscape than this gallery's
    smaller examples, so we check "compatible", not "identical" per parameter.

    Returns ``p3.sigma`` (== ``p4.sigma`` by the tie) for the plotting step.
    """
    sf_p3_sigma = sf_result.parameters["p3.sigma"].value
    sf_p4_sigma = sf_result.parameters["p4.sigma"].value
    print("Tie verification (spectrafit ExprEdge, p4.sigma = p3.sigma):")
    print(f"  p3.sigma = {sf_p3_sigma:.8f}")
    print(f"  p4.sigma = {sf_p4_sigma:.8f}")
    print(f"  Difference = {abs(sf_p3_sigma - sf_p4_sigma):.2e}")
    print()

    assert sf_result.success
    assert lm_result.success
    sf_r2 = sf_result.r_squared
    assert abs(sf_r2 - lm_r2) < 0.02, (
        f"Aggregate fit quality (R2) should be close even if individual overlapping "
        f"peak parameters are not: spectrafit R2={sf_r2:.6f}, lmfit R2={lm_r2:.6f}"
    )
    assert abs(sf_p3_sigma - sf_p4_sigma) < 1e-6

    return sf_p3_sigma


sf_p3_sigma = verify_tie_and_assertions(sf_result, lm_result, lm_r2)

8-peak overlapping spectrum fitted by both spectrafit and lmfit, with the tied doublet width annotated

Why this works

spectrafit_vs_lmfit_moderate.md already cross-checks spectrafit against a hand-built lmfit composite on a moderately busy spectrum. This example goes further: an 8-peak, three-lineshape-family, overlapping spectrum with a linear background and a physically-motivated tied linewidth — the kind of "real-world messy" spectrum a single clean 2- or 4-peak demo never has to confront. It is the genuinely harder sibling, not a repeat.

The scenario. An XPS-style core-level region built from eight overlapping components:

Node Lineshape Role
p0 Gaussian dominant instrumental-broadening-limited peak
p1, p2 pseudo-Voigt mixed Gaussian/Lorentzian chemical-state components
p3, p4 Lorentzian one spin-orbit doublet — same natural linewidth by physics
p5, p6 Gaussian shake-up satellites
p7 Lorentzian trace / plasmon-loss tail
bg Linear slowly-varying background

p3 and p4 are the two components of one spin-orbit doublet: physically, they share the same natural (lifetime-broadened, Lorentzian) linewidth, so p4.sigma is tied to p3.sigma via a graph-level ExprEdge — the same mechanism documented in shared_params.md, applied here for a physical reason rather than only as a syntax demo.

Two independent fits, one dataset. The same synthetic data is fitted twice: once with spectrafit_core.fit() (Rust "lm" solver, the tied linewidth enforced via ExprEdge), and once with a hand-built lmfit composite that mirrors the real oracle backend at python/oracles/backends/_lmfit.py — one lmfit.Model(fn, prefix=...) per node summed with +, and the tie re-expressed as an lmfit parameter expression via the exact dotted-to-underscore regex translation that backend uses for expr_edges ("p3.sigma" → "p3_sigma").

What just happened

  1. Data creation — eight overlapping peaks (three Gaussian, three Lorentzian, two pseudo-Voigt) plus a linear background, spanning a 16-unit window with peak spacing (1–1.5) comparable to peak width (0.4–0.65) — a genuinely overlapping, not just adjacent, spectrum.

  2. build_graph() — an 8-node FitGraph plus one ExprEdge tying p4.sigma to p3.sigma, following exactly the pattern in shared_params.py.

  3. build_lmfit_composite() — the independent oracle, built by hand from the same local peak formulas (never imported from spectrafit-core), so the two fits share a model but not an implementation. The tie is applied with params["p4_sigma"].set(expr="p3_sigma") after translating the dotted spectrafit expression through the same regex python/oracles/backends/_lmfit.py uses for every expr_edges entry.

  4. Side-by-side report — success, chi2, iteration counts (n_iter for spectrafit, nfev for lmfit — the two solvers do not count "iterations" the same way, so only the wall-clock timing and chi2/\(R^2\) are apples-to-apples), and every one of the 27 free parameters, spectrafit vs. lmfit, with their absolute difference.

  5. What was actually observed (read this before trusting any 8-peak tied fit's agreement in general). On this particular scenario, from this particular starting guess, spectrafit's Rust "lm" solver and lmfit's SciPy leastsq wrapper converged to values agreeing to within \(2 \times 10^{-4}\) on every parameter and matching \(R^2\) to four decimal places — tighter agreement than the module docstring's caution would necessarily predict. That is not a general guarantee: overlapping peaks trade amplitude and width against their neighbors along near-degenerate directions of the cost surface, and a different noise draw, a different initial guess, or a different tied pair could easily land the two independent solvers in different corners of a shallow valley — close in chi2, not necessarily close in every individual parameter. The script's own assertions reflect that: a loose \(R^2\)-agreement check (< 0.02), not a tight per-parameter one, because a tight per-parameter assertion is not something either solver's local optimizer actually guarantees on a problem this overlapping. spectrafit was also markedly faster in wall-clock terms here (single-digit milliseconds vs. tens of milliseconds for lmfit's much higher function-evaluation count) — consistent with, but not a substitute for, the project's own aggregate speed comparison on the Performance page.

  6. Tie verification — p4.sigma and p3.sigma are asserted identical to machine precision on the spectrafit side (the ExprEdge contract, not a statistical claim), independent of however close or far the two backends land from each other on the rest of the parameter space.

See also