From a fit to uncertainty¶
Start with the quickstart, then keep three different questions separate: uncertainty in a parameter, uncertainty in a fitted mean, and variation in a future measurement.
Mean confidence versus observation prediction¶
At a chosen coordinate, the Delta approximation gives
Here \(V\) is parameter covariance and \(j\) the model's parameter gradient. The added future noise must be independent of parameter estimation. This self-contained example uses known concentration uncertainty:
import numpy as np
from scipy.optimize import curve_fit
from curvefit_stats import audit_fit, generate_error_bands, propagate_uncertainty
def decay(time, amplitude, rate):
return amplitude * np.exp(-rate * time)
time = np.linspace(0, 5, 45)
sigma = 0.08
rng = np.random.default_rng(2026)
observed = decay(time, 3.0, 0.7) + rng.normal(0, sigma, time.size)
popt, pcov = curve_fit(
decay, time, observed, p0=[2.5, 0.5], sigma=sigma, absolute_sigma=True,
)
grid = np.linspace(0, 5, 101)
mean_lo, mean_hi = generate_error_bands(
decay, grid, popt, pcov, reference="normal",
)
pred_lo, pred_hi = generate_error_bands(
decay, grid, popt, pcov, reference="normal",
interval_type="prediction", prediction_variance=sigma**2,
)
assert np.all(pred_hi - pred_lo > mean_hi - mean_lo)
metrics = audit_fit(decay, time, observed, popt, sigma=sigma, absolute_sigma=True)
assert metrics["dof"] == 43
half_life = propagate_uncertainty(
lambda amplitude, rate: np.log(2) / rate,
popt, pcov, n_samples=20000, random_state=2026,
)
assert half_life["n_valid"] == 20000
print(half_life["lower"], half_life["upper"])
The normal reference matches the known-error covariance here. fit_summary
always uses approximate Student-t with N-p, so it is not an automatically
calibrated known-error table. Both bands remain pointwise, and linearization may
be poor under strong curvature, weak identification or constraints.
The half-life calculation samples a Gaussian parameter approximation. Its finite Monte Carlo summary is not a proof of finite population moments: a Gaussian rate can be arbitrarily near zero. Physical bounds and likelihood asymmetry may call for a different uncertainty model.
What changes at an active bound?¶
A nonnegative background can reach its lower constraint. Ordinary full-matrix propagation must then give way to explicitly conditional inference:
import warnings
from curvefit_stats import robust_bounded_pcov
def with_background(time, amplitude, rate, background):
return amplitude * np.exp(-rate * time) + background
rng = np.random.default_rng(19)
observed_bound = with_background(time, 3.0, 0.7, -0.15)
observed_bound = observed_bound + rng.normal(0, 0.04, time.size)
bounds = ([0, 0, 0], [np.inf, np.inf, np.inf])
bounded, _ = curve_fit(with_background, time, observed_bound, [3, 0.7, 0.05], bounds=bounds)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UserWarning)
conditional = robust_bounded_pcov(
with_background, time, observed_bound, bounded, bounds,
)
assert any("Bound-active parameter rows" in str(item.message) for item in caught)
assert np.isnan(conditional[2]).all()
def fixed_background(time, amplitude, rate):
return with_background(time, amplitude, rate, bounded[2])
free_lo, free_hi = generate_error_bands(
fixed_background, grid, bounded[:2], conditional[:2, :2], dof=time.size - 2,
)
assert np.isfinite(free_lo).all() and np.isfinite(free_hi).all()
The synthetic true background conflicts deliberately with the imposed bound. Computing a finite free covariance does not establish model adequacy. These bands hold the selected background fixed; they do not quantify its one-sided uncertainty or calibrate selection of the active set.
Read conditional covariance, diagnostic assumptions and the API contracts before adapting this workflow.