Skip to content

Holding a Parameter Fixed

Synthetic example

The fixture below is a single seeded peak with one parameter's "known" value baked in by construction — chosen to demonstrate the vary=False mechanism, not a sweep proving fixed-parameter fits behave well across real independently-measured calibration scenarios.

Quick example

You already know a parameter's value from an independent measurement — a background level read off a blank/dark scan, or an instrument-broadening \(\sigma\) from a calibration standard — and want to hold it fixed instead of asking this same noisy dataset to estimate it too. Set vary=False on that Parameter, with no expr.

def synthesize_data() -> tuple[np.ndarray, np.ndarray]:
    """Synthesize a Gaussian peak on top of ``TRUE_BACKGROUND`` (with noise)."""
    rng = np.random.default_rng(11)
    x = np.linspace(-3, 3, 120)
    peak = 2.0 * np.exp(-0.5 * ((x - 0.5) / 0.7) ** 2)
    noise = rng.normal(0, 0.05, len(x))
    y = peak + TRUE_BACKGROUND + noise
    return x, y


x, y = synthesize_data()

TRUE_BACKGROUND stands in for a level measured independently of this dataset — known exactly, not something to estimate from the same noisy points. Build two graphs against the same data: one pins bg.c at that known value, the other leaves it free to compare against.

def build_graph(*, fix_background: bool) -> FitGraph:
    """One Gaussian peak + one constant background node.

    When ``fix_background`` is True, ``bg.c`` is constructed with
    ``value=TRUE_BACKGROUND, vary=False`` — pinned to the known value and
    excluded from the optimizer's free set for the whole fit. When False, it
    starts from the same value but is left ``vary=True`` (the default) and
    must be estimated from this noisy data like every other free parameter.
    """
    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=TRUE_BACKGROUND, vary=not fix_background),
                },
            ),
        ],
    )

Both graphs are fit against the identical (x, y) — the only difference between the two calls below is bg.c's vary flag.

def fit_both(x: np.ndarray, y: np.ndarray) -> dict[str, FitResult]:
    """Fit the same noisy data with ``bg.c`` fixed, then again with it free."""
    data = MeasurementData(x=x.tolist(), y=y.tolist())
    return {
        "fixed": fit(build_graph(fix_background=True), data),
        "free": fit(build_graph(fix_background=False), data),
    }


results = fit_both(x, y)
fixed_result, free_result = results["fixed"], results["free"]

Two facts are guaranteed by the mechanism itself, not just this one seeded example: fixing bg.c removes it from the free-parameter vector, so dof = n_points - n_free is exactly one higher in the fixed run, and its stderr is None there since a fixed parameter was never part of the optimization the covariance matrix describes.

def compare(fixed_result: FitResult, free_result: FitResult) -> None:
    """Report dof, per-parameter stderr, and the fixed parameter's absent stderr.

    Two facts are *guaranteed* and asserted below: fixing ``bg.c`` removes it
    from the free-parameter vector, so ``dof = n_points - n_free`` is exactly
    one higher in the fixed model; and ``ParameterResult.stderr`` for ``bg.c``
    is ``None`` in the fixed run, since a fixed parameter was never part of
    the optimization the covariance matrix describes. The peak parameters'
    stderr being *tighter* in the fixed run (printed below, not hard-asserted
    beyond a non-strict bound) is the expected direction for a correctly-known
    fixed value — removing a genuinely-correlated nuisance parameter from the
    free set cannot increase the remaining parameters' Fisher information —
    but the exact margin is specific to this one seeded example, not a
    general accuracy claim.
    """
    print(f"{'':10s} {'dof':>5s} {'amplitude stderr':>18s} {'sigma stderr':>14s}")
    for name, result in (("fixed", fixed_result), ("free", free_result)):
        amp_stderr = result.parameters["peak.amplitude"].stderr
        sigma_stderr = result.parameters["peak.sigma"].stderr
        assert amp_stderr is not None
        assert sigma_stderr is not None
        print(f"{name:10s} {result.dof:5d} {amp_stderr:18.5f} {sigma_stderr:14.5f}")

    bg_fixed_stderr = fixed_result.parameters["bg.c"].stderr
    bg_free_stderr = free_result.parameters["bg.c"].stderr
    print(f"\nbg.c stderr — fixed: {bg_fixed_stderr!r}, free: {bg_free_stderr!r}")

    assert fixed_result.success
    assert free_result.success
    assert fixed_result.dof == free_result.dof + 1, (
        "fixing bg.c removes it from the free set, raising dof by exactly 1"
    )
    assert bg_fixed_stderr is None, (
        "a vary=False parameter is never in the optimization vector, so its stderr is not estimable"
    )
    assert bg_free_stderr is not None
    fixed_amp_stderr = fixed_result.parameters["peak.amplitude"].stderr
    free_amp_stderr = free_result.parameters["peak.amplitude"].stderr
    assert fixed_amp_stderr is not None
    assert free_amp_stderr is not None
    assert fixed_amp_stderr <= free_amp_stderr * 1.05, (
        "a correctly-known fixed background should not noticeably widen the "
        "peak amplitude's uncertainty relative to fitting it"
    )


compare(fixed_result, free_result)

Peak + background fit with the background held fixed at a known value

Why this works

Distinct from shared_params.md: that page ties one parameter's value to another fitted parameter via expr. This example fixes a parameter to a known constant, with no expr involved at all. Parameter's own docstring calls out vary as "whether the solver may adjust this parameter" — vary=False excludes it from the free set entirely for the whole fit, regardless of how close or far its constructed value is from any other parameter.

What just happened

  1. Data creation — a single Gaussian peak on top of a background level (TRUE_BACKGROUND = 0.35) known exactly, as if measured from a blank scan.

  2. Two graphs, one dataset — build_graph(fix_background=True) constructs bg.c as Parameter(value=TRUE_BACKGROUND, vary=False); the free variant uses the same starting value with vary=True (the default).

  3. dof differs by exactly one — fixing bg.c removes it from the optimizer's free set, so the fixed run's dof is one higher than the free run's.

  4. stderr is absent for the fixed parameter — ParameterResult.stderr for bg.c is None in the fixed run, since it was never part of the covariance matrix the solver estimated.

  5. Tighter peak-parameter uncertainty — with a correctly-known fixed background, the peak's amplitude/sigma stderr come out tighter than the free run's on this example, matching the expected direction (removing a genuinely-correlated nuisance parameter from the free set cannot increase the remaining parameters' Fisher information) — the exact margin is specific to this one seeded example, not a general accuracy claim.

See also