Skip to content

Shared Parameters Across Peaks

Synthetic example

The fixture below is two seeded, overlapping peaks with a known-truth shared width — chosen to demonstrate that ExprEdge and Parameter.expr produce numerically identical results, not a sweep proving parameter tying behaves well across arbitrary peak counts or overlap degrees.

Quick example — using ExprEdge (graph-level)

You have multiple peaks in a single spectrum and want to enforce that some parameters are identical across peaks — for example, two Lorentzian peaks that must share the same line width (\(\sigma\)), or three multiplet lines with identical broadening.

The script below builds and fits the ExprEdge form, verifies the tie holds, then — in its "Equivalent form" section — re-solves the same problem with Parameter.expr and asserts the two surfaces agree to within 1e-6, so both forms are demonstrated (and kept honest against each other) in one run rather than only asserted in prose.

def synthesize_data() -> tuple[np.ndarray, np.ndarray]:
    """Synthesize two overlapping Gaussians that share one true sigma.

    peak1 sits at center=0 with amplitude=3, peak2 at center=2.5 with
    amplitude=2; both share sigma=0.6, plus Gaussian measurement noise
    (``sigma=0.05``, seeded for reproducibility).
    """
    rng = np.random.default_rng(42)
    x = np.linspace(-2, 4, 120)
    sigma_true = 0.6
    center1_true, center2_true = 0.0, 2.5
    peak1_true = 3.0 * np.exp(-0.5 * ((x - center1_true) / sigma_true) ** 2)
    peak2_true = 2.0 * np.exp(-0.5 * ((x - center2_true) / sigma_true) ** 2)
    noise = rng.normal(0, 0.05, len(x))
    y = peak1_true + peak2_true + noise
    return x, y


x, y = synthesize_data()

Both peaks are generated from the same true sigma, because that is what an instrument's broadening function would actually do — the fit below needs to recover that shared width, not two independently-drifting ones. Encode that physical constraint directly in the graph: two Gaussian nodes plus one ExprEdge tying peak2.sigma to peak1.sigma.

def build_graph_expr_edge() -> FitGraph:
    """Build a FitGraph with two Gaussian peaks tied via an ``ExprEdge``.

    ``peak2.sigma`` is tied to ``peak1.sigma`` through one graph-level
    ``ExprEdge`` -- the primary tie surface documented in
    ``shared_params.md``. This reduces the degrees of freedom by 1:
    ``peak1.sigma`` is a free variable, ``peak2.sigma`` is dependent.
    """
    return FitGraph(
        nodes=[
            ModelNodeSpec(
                id="peak1",
                model_type=ModelType.GAUSSIAN,
                parameters={
                    "amplitude": Parameter(value=2.5),
                    "center": Parameter(value=0.5),
                    "sigma": Parameter(value=0.5, min=1e-3),
                },
            ),
            ModelNodeSpec(
                id="peak2",
                model_type=ModelType.GAUSSIAN,
                parameters={
                    "amplitude": Parameter(value=2.0),
                    "center": Parameter(value=2.0),
                    "sigma": Parameter(value=0.5, min=1e-3),
                },
            ),
        ],
        expr_edges=[
            ExprEdge(
                target_node="peak2",
                target_param="sigma",
                expression="peak1.sigma",
            ),
        ],
    )


graph = build_graph_expr_edge()

Nothing mechanically new happens at fit time versus fitting.md — the same default LM solver runs, it just re-evaluates and enforces the ExprEdge tie on every iteration.

def fit_with_expr_edge(graph: FitGraph, x: np.ndarray, y: np.ndarray) -> FitResult:
    """Wrap ``(x, y)`` as ``MeasurementData`` and fit the ExprEdge-tied graph.

    The optimizer adjusts the 5 free variables (peak1 amplitude, center,
    sigma; peak2 amplitude, center) while the tie constraint is enforced at
    each iteration.
    """
    data = MeasurementData(x=x.tolist(), y=y.tolist())
    return fit(graph, data)


result = fit_with_expr_edge(graph, x, y)

Check both peaks' reported sigma in the output below: they should be bit-for-bit equal, not merely close, since peak2.sigma is never independently varied — it's derived from peak1.sigma every time.

def report_expr_edge_result(result: FitResult) -> None:
    """Print success/R² and every fitted parameter, tied ones included.

    ``result.parameters`` includes both ``peak1.sigma`` and ``peak2.sigma``,
    but they are numerically identical because the tie is enforced. A tied
    parameter reports ``stderr=None`` since it is not independently varied
    by the solver, so it prints "tied" instead of a numeric standard error.
    """
    print(f"Success: {result.success}")
    print(f"R²: {result.r_squared:.6f}")
    print()

    print("Fitted parameters:")
    for param_name, param in sorted(result.parameters.items()):
        stderr_str = f"{param.stderr:8.4f}" if param.stderr is not None else "    tied"
        print(f"{param_name:20s} = {param.value:8.4f} ± {stderr_str}")
    print()


report_expr_edge_result(result)

Printed values only show a handful of decimal places, which can hide a tie that's "close enough to eyeball" but not actually enforced — so verify the tie numerically against machine epsilon instead of trusting the printout.

def verify_tie(result: FitResult) -> tuple[float, float]:
    """Print ``peak1.sigma`` vs. ``peak2.sigma`` and return both.

    Confirms the tie holds: the difference between the two should be
    negligible (< 1e-14 machine epsilon).
    """
    sigma1 = result.parameters["peak1.sigma"].value
    sigma2 = result.parameters["peak2.sigma"].value
    print("Tie verification:")
    print(f"  peak1.sigma = {sigma1:.8f}")
    print(f"  peak2.sigma = {sigma2:.8f}")
    print(f"  Difference  = {abs(sigma1 - sigma2):.2e}")
    return sigma1, sigma2


sigma1, sigma2 = verify_tie(result)

Two Gaussian peaks with a matching shared-width annotation

Why this works

There are two equivalent ways to express a parameter tie — choose whichever reads most naturally in your code:

Surface How to declare When to use
ExprEdge (graph-level) Add an ExprEdge to FitGraph.expr_edges When building or composing graphs programmatically; best for complex, multi-edge topologies.
Parameter.expr (per-param) Set expr="source_node.param" on the target Parameter When building a node inline and the tie is a simple identity; no separate edge list needed.

Both surfaces compile to the same dependency-ordered tied-plan, so the fit result is numerically identical regardless of which surface you use. The LM-family solvers (lm, trf, geodesic, dogleg, newton-cg, irls) apply the tied-plan on every iteration. The global (differential-evolution) solver searches with the tied parameters held at their seed values and then applies the tie in its post-search LM refinement, so its final result is tie-correct. (solver="varpro" does not fit tied graphs at all — it rejects them; see the Model Reference.) Setting the same target parameter via both surfaces simultaneously raises a DuplicateExprTarget error.

What just happened

  1. Data creation — we synthesized two overlapping Gaussians: peak1 at center=0 with amplitude=3, peak2 at center=2.5 with amplitude=2, both with \(\sigma=0.6\).

  2. Graph definition — we built a FitGraph with two GAUSSIAN nodes, plus one ExprEdge:

    • expr_edges[0] ties peak2.sigma to peak1.sigma, meaning throughout the fit, peak2.sigma is automatically updated to match peak1.sigma.
    • This reduces the degrees of freedom by 1 (peak1.sigma is a free variable; peak2.sigma is dependent).
  3. Fit execution — the optimizer adjusts the 5 free variables (peak1 amplitude, center, sigma; peak2 amplitude, center) and the tie constraint is enforced at each iteration.

  4. Result inspection — the parameters dict includes both peak1.sigma and peak2.sigma, but they are numerically identical because the tie is enforced. In the output above, both report ~0.6 (the true value).

  5. Tie verification — we confirm that the difference between peak1.sigma and peak2.sigma is negligible (< 1e-14 machine epsilon).

Equivalent form — using Parameter.expr (per-parameter)

The same tie can be declared entirely inside the target Parameter itself, without adding an ExprEdge to the graph. build_graph_parameter_expr() re-solves the same data with expr="peak1.sigma" set directly on peak2.sigma's Parameter and no expr_edges at all; fit_with_parameter_expr() fits it, and check_equivalence() prints and asserts that the fit result is numerically identical to the ExprEdge form above.

def build_graph_parameter_expr() -> FitGraph:
    """Build the equivalent FitGraph using ``Parameter.expr`` instead of ``ExprEdge``.

    The same tie can be declared entirely inside the target ``Parameter``
    itself -- ``peak2.sigma`` sets ``expr="peak1.sigma"`` directly -- with no
    ``ExprEdge`` in the graph at all. ``expr_edges`` is intentionally left
    empty; per ``shared_params.md``'s "Equivalence guarantee", both surfaces
    compile to the same dependency-ordered tied-plan, so the fit result must
    be numerically identical to the ``ExprEdge`` form.
    """
    return FitGraph(
        nodes=[
            ModelNodeSpec(
                id="peak1",
                model_type=ModelType.GAUSSIAN,
                parameters={
                    "amplitude": Parameter(value=2.5),
                    "center": Parameter(value=0.5),
                    "sigma": Parameter(value=0.5, min=1e-3),
                },
            ),
            ModelNodeSpec(
                id="peak2",
                model_type=ModelType.GAUSSIAN,
                parameters={
                    "amplitude": Parameter(value=2.0),
                    "center": Parameter(value=2.0),
                    # Tie declared inline: peak2.sigma is derived from peak1.sigma.
                    "sigma": Parameter(
                        value=0.5,
                        min=1e-3,
                        expr="peak1.sigma",
                        vary=False,
                    ),
                },
            ),
        ],
        # expr_edges intentionally empty -- the tie lives in Parameter.expr only.
    )


graph_expr = build_graph_parameter_expr()

Re-solving with the other tying surface, rather than just asserting in prose that the two are equivalent, is what turns "should be the same" into a checked claim — the fit below runs independently against the same data.

def fit_with_parameter_expr(
    graph_expr: FitGraph,
    x: np.ndarray,
    y: np.ndarray,
) -> FitResult:
    """Wrap the same ``(x, y)`` as ``MeasurementData`` and fit the ``Parameter.expr`` graph."""
    data = MeasurementData(x=x.tolist(), y=y.tolist())
    return fit(graph_expr, data)


result_expr = fit_with_parameter_expr(graph_expr, x, y)

The equivalence check proves the two surfaces are not just similar but numerically interchangeable: same \(\chi^2\), same tied sigma, to within 1e-6 — so the choice between ExprEdge and Parameter.expr really is just a matter of which reads more naturally for a given graph.

def check_equivalence(
    result: FitResult,
    result_expr: FitResult,
    sigma1: float,
    sigma2: float,
) -> None:
    """Print R² for both surfaces and assert they agree to within ``1e-6``.

    Confirms the "Equivalence guarantee" in ``shared_params.md``: ``ExprEdge``
    and ``Parameter.expr`` compile to the same dependency-ordered tied-plan,
    so chi2 and the tied sigma values must match across both surfaces --
    asserted here rather than just asserted in prose.
    """
    print()
    print("Parameter.expr form (equivalence check):")
    print(f"  R² (ExprEdge)      = {result.r_squared:.10f}")
    print(f"  R² (Parameter.expr) = {result_expr.r_squared:.10f}")

    assert result_expr.success
    assert abs(result.chi2 - result_expr.chi2) < 1e-6, "chi2 must match across surfaces"
    assert abs(sigma1 - result_expr.parameters["peak1.sigma"].value) < 1e-6
    assert abs(sigma2 - result_expr.parameters["peak2.sigma"].value) < 1e-6
    print("  Both surfaces agree within 1e-6 — equivalence confirmed.")


check_equivalence(result, result_expr, sigma1, sigma2)

Equivalence guarantee

ExprEdge and Parameter.expr are two syntax forms for the same constraint: both are compiled into the same dependency-ordered tied-plan that the solver evaluates on every iteration. The parity test tests/parity/test_param_expr_surface_parity.py::test_param_expr_matches_expr_edge asserts that recovered parameters and \(\chi^2\) agree to rel=1e-6 across the two surfaces.

Do not use both at once. Targeting the same parameter with both a Parameter.expr and a matching ExprEdge raises a DuplicateExprTarget error at compilation time.

See also

  • Related examples: fitting.md (basic single-fit), multi_dataset.md (per-slice shared parameters).
  • Tests: tests/unit/spectrafit_core/test_fit.py::test_fit_accepts_expr_edges (ExprEdge end-to-end), tests/unit/spectrafit_core/test_fit.py::test_fit_honors_parameter_expr (Parameter.expr end-to-end), tests/parity/test_param_expr_surface_parity.py::test_param_expr_matches_expr_edge (equivalence invariant).
  • API docs: ExprEdge, FitGraph, Parameter.
  • Glossary: Glossary — definitions for this page's project-specific terms (ExprEdge, Parameter.expr, tied parameters, LM).