Skip to content

Single-Dataset Fitting

Synthetic example

The fixture below is a single seeded Gaussian peak plus constant background with known truth parameters (amplitude 2.0, center 0.5) — chosen so the fit can be checked against a known answer, not a sweep proving this workflow generalizes across peak shapes or noise levels.

Context

When to use this pattern. You have a single measurement (x, y data) and want to fit it with one or more peak models. This is the simplest spectroscopy workflow: create a FitGraph with one or more peak nodes, pass your measured data, and extract the fitted parameters. The result includes the fitted curve, goodness-of-fit metrics (\(R^2\), \(\chi^2\)), and uncertainty estimates.

Quick example

def synthesize_data() -> tuple[np.ndarray, np.ndarray]:
    """Synthesize a single Gaussian peak (amplitude 2.0, center 0.5) in noisy background.

    Adds a 0.1 constant background plus Gaussian measurement noise
    (``sigma=0.05``, seeded for reproducibility).
    """
    rng = np.random.default_rng(42)
    x = np.linspace(-3, 3, 100)
    peak = 2.0 * np.exp(-0.5 * ((x - 0.5) / 0.8) ** 2)
    background = 0.1
    noise = rng.normal(0, 0.05, len(x))
    y = peak + background + noise
    return x, y


x, y = synthesize_data()

We now have noisy (x, y) data with a known ground truth (amplitude 2.0, center 0.5) — enough to check the fit against later. Next, describe the model as a graph: one Gaussian peak node plus one constant background node, each seeded with an initial guess that's deliberately off from the truth.

def build_graph() -> FitGraph:
    """Build a FitGraph: one Gaussian peak + one constant background.

    The ``sigma`` parameter has a lower bound ``min=1e-3`` to prevent the
    optimizer from driving it to zero.
    """
    return FitGraph(
        nodes=[
            ModelNodeSpec(
                id="peak",
                model_type=ModelType.GAUSSIAN,
                parameters={
                    "amplitude": Parameter(value=1.5),
                    "center": Parameter(value=0.0),
                    "sigma": Parameter(value=0.5, min=1e-3),
                },
            ),
            ModelNodeSpec(
                id="bg",
                model_type=ModelType.CONSTANT,
                parameters={
                    "c": Parameter(value=0.0),
                },
            ),
        ],
    )


graph = build_graph()

With data and model in hand, run the solver. fit() dispatches to the default "lm" (Levenberg-Marquardt) solver, which iterates until the residuals stop improving.

def run_fit(graph: FitGraph, x: np.ndarray, y: np.ndarray) -> FitResult:
    """Wrap the measurement data and invoke the Levenberg-Marquardt solver.

    ``fit(graph, data)`` iteratively adjusts parameters to minimize the
    residuals until convergence.
    """
    data = MeasurementData(x=x.tolist(), y=y.tolist())
    return fit(graph, data)


result = run_fit(graph, x, y)

The FitResult carries everything needed to judge the fit: whether it converged, how good it is, and the recovered parameters with uncertainty.

def report_result(result: FitResult) -> None:
    """Print success/goodness-of-fit, fitted parameters, and residual summary.

    Reads ``success`` (did the solver converge), ``r_squared`` and ``chi2``
    (goodness-of-fit), ``parameters`` (value ± stderr per fitted parameter,
    1sigma), and ``residuals`` (observed minus fitted, whose RMS and range
    describe noise level and systematic error).
    """
    print(f"Success: {result.success}")
    print(f"R²: {result.r_squared:.6f}")
    print(f"χ²: {result.chi2:.6f}")
    print()

    for param_name, param in sorted(result.parameters.items()):
        print(f"{param_name:15s} = {param.value:8.4f} ± {param.stderr:8.4f}")
    print()

    residuals = np.array(result.residuals)
    print(f"Residual RMS:   {np.sqrt(np.mean(residuals**2)):.6f}")
    print(f"Residual range: [{np.min(residuals):.6f}, {np.max(residuals):.6f}]")


report_result(result)

Single-dataset Gaussian fit with residuals subplot

What just happened

  1. Data creation — we synthesized x, y values (100 points) with a 2.0-amplitude Gaussian centered at 0.5, a 0.1 constant background, and Gaussian noise.

  2. Graph definition — we built a FitGraph with two nodes:

    • peak: a Gaussian with initial guesses (amplitude=1.5, center=0, sigma=0.5).
    • bg: a constant with initial guess 0.

    The sigma parameter has a lower bound min=1e-3 to prevent the optimizer from driving it to zero.

  3. Fit execution — fit(graph, data) invokes the Levenberg-Marquardt solver (default), which iteratively adjusts parameters to minimize the residuals until convergence.

  4. Result inspection — we read:

    • success — True if the solver converged.
    • r_squared — the coefficient of determination (aim for > 0.99 on good data).
    • chi2 — the sum of squared residuals.
    • parameters — dict of fitted parameters with value (point estimate) and stderr (estimated standard error, \(1\sigma\) — the square root of the covariance diagonal, not a confidence-interval half-width).
    • residuals — the observed minus fitted values; their RMS and range tell you about noise level and systematic errors.

See also