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)
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¶
-
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\).
-
Graph definition — we built a
FitGraphwith twoGAUSSIANnodes, plus oneExprEdge:expr_edges[0]tiespeak2.sigmatopeak1.sigma, meaning throughout the fit,peak2.sigmais automatically updated to matchpeak1.sigma.- This reduces the degrees of freedom by 1 (peak1.sigma is a free variable; peak2.sigma is dependent).
-
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.
-
Result inspection — the
parametersdict includes bothpeak1.sigmaandpeak2.sigma, but they are numerically identical because the tie is enforced. In the output above, both report ~0.6 (the true value). -
Tie verification — we confirm that the difference between
peak1.sigmaandpeak2.sigmais 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).
