When a Fit Fails¶
Synthetic example
A single seeded Gaussian with light noise. The data here is deliberately easy — every failure on this page is caused by where the fit was told to start, not by a hard measurement. It demonstrates how failure is reported; it is not a survey of how often each failure mode occurs.
Quick example¶
Three fits of the same data, from different starting guesses, show that
success alone is necessary but not sufficient — one reports
success=False with the reason named, one reports success=True with a
negative \(r^2\), and one recovers the truth from that same bad guess.
def synthesize_data() -> tuple[np.ndarray, np.ndarray]:
"""One clean, well-sampled Gaussian peak with light noise.
Deliberately easy data: nothing below fails because the measurement is
hard. Every failure on this page is caused by where the fit was told to
start, not by the data.
"""
rng = np.random.default_rng(7)
x = np.linspace(-4, 4, 120)
y_true = AMPLITUDE_TRUE * np.exp(-0.5 * ((x - CENTER_TRUE) / SIGMA_TRUE) ** 2)
return x, y_true + rng.normal(0, 0.05, len(x))
x, y = synthesize_data()
def build_graph(amplitude: float, center: float, sigma: float) -> FitGraph:
"""One Gaussian, with the *initial guess* supplied by the caller."""
return FitGraph(
nodes=[
ModelNodeSpec(
id="peak",
model_type=ModelType.GAUSSIAN,
parameters={
"amplitude": Parameter(value=amplitude),
"center": Parameter(value=center),
"sigma": Parameter(value=sigma, min=1e-3),
},
),
],
)
The three fits differ only in the initial guess handed to build_graph() and,
for the last one, in the solver. Nothing about the data or the model changes.
def three_fits(x: np.ndarray, y: np.ndarray) -> dict[str, FitResult]:
"""Same data, same model, three outcomes.
``reported`` and ``silent`` differ only in their starting guess; the
solver is local ``"lm"`` in both. ``recovered`` reuses ``silent``'s bad
guess and changes only the solver.
"""
data = MeasurementData(x=x.tolist(), y=y.tolist())
return {
"reported": fit(build_graph(50.0, -3.9, 0.05), data, FitOptions(solver="lm")),
"silent": fit(build_graph(3.0, -3.9, 0.8), data, FitOptions(solver="lm")),
"recovered": fit(build_graph(3.0, -3.9, 0.8), data, FitOptions(solver="global")),
}
results = three_fits(x, y)
def inspect(results: dict[str, FitResult], y: np.ndarray) -> None:
"""Print what each fit reported, and check the three outcomes hold.
The ``|A|/max|y|`` column is the quantity the degeneracy guard actually
tests. Watch it straddle the 1% threshold between the first two rows
while ``r^2`` stays essentially identical -- that is the guard's
boundary, made visible.
"""
y_max_abs = float(np.abs(y).max())
header = (
f"{'case':11s} {'success':>8s} {'r^2':>9s} {'amplitude':>10s} {'|A|/max|y|':>11s} message"
)
print(header)
for name, result in results.items():
amp = result.parameters["peak.amplitude"].value
print(
f"{name:11s} {result.success!s:>8s} {result.r_squared:9.4f} "
f"{amp:10.4f} {abs(amp) / y_max_abs:11.4f} {result.message}",
)
reported, silent, recovered = results["reported"], results["silent"], results["recovered"]
# The guard fires: amplitude collapsed under 1% of max|y| with r^2 < 0.
assert not reported.success
assert "degenerate_fit" in reported.message
assert reported.r_squared < 0
# The guard stays silent on a fit that is just as wrong, because its
# amplitude landed above the 1% threshold. This is the case the page exists
# for: `success` alone would have told a caller everything was fine.
assert silent.success
assert silent.r_squared < 0
assert abs(silent.parameters["peak.amplitude"].value) / y_max_abs > 1e-2
# Same bad guess, global solver, truth recovered.
assert recovered.success
assert recovered.r_squared > 0.99
assert abs(recovered.parameters["peak.amplitude"].value - AMPLITUDE_TRUE) < 0.05
assert abs(recovered.parameters["peak.center"].value - CENTER_TRUE) < 0.05
inspect(results, y)
Printed output:
case success r^2 amplitude |A|/max|y| message
reported False -0.5252 -0.0032 0.0011 degenerate_fit (r2=-5.252e-1 < 0, peak amplitude collapsed)
silent True -0.5250 -0.0820 0.0273 converged_ftol
recovered True 0.9982 2.9977 0.9988 converged_ftol
Why this works¶
Every other page in this gallery ends success=True. That is not what
fitting is like, and a reader who only ever sees converged examples learns
nothing about how this library reports trouble — or about where its reporting
stops.
| case | outcome |
|---|---|
reported |
success=False, and the message names the reason |
silent |
success=True with \(r^2 = -0.53\) — worse than a flat line |
recovered |
success=True, truth recovered, from silent's own bad guess |
The middle row is why this page exists. success is necessary but not
sufficient. Read r_squared and the recovered parameters as well.
Why one failure is reported and the other is not¶
The demotion rule is in crates/spectrafit-solver/src/postfit.rs. When a fit
converges with \(r^2 < 0\), it is turned into a failure only if some free
.amplitude or .height parameter has fallen below 1% of max |y|:
(lk.ends_with(".amplitude") || lk.ends_with(".height"))
&& final_flat.get(k).is_some_and(|&v| v.abs() / y_max_abs < 1e-2)
That single threshold is the entire difference between the first two rows. The
|A|/max|y| column straddles it — 0.0011 against 0.0273 — while \(r^2\) is
identical to three decimal places. The silent fit converged to a narrow
negative Gaussian sitting at \(x \approx -2.7\), about 2.7% of the true peak
height: collapsed by any ordinary reading, but above the line the guard draws.
The guard is narrow on purpose — a broad "\(r^2 < 0\) means failure" rule would
misreport legitimate fits of data whose baseline the model is not asked to
describe. The consequence is still worth knowing: a converged fit can be
arbitrarily wrong and still report success=True.
What just happened¶
-
reported— the guard fires. Starting atamplitude=50, center=-3.9, sigma=0.05, the fit drove the amplitude to \(-0.0032\), or 0.1% of max |y|. Below the 1% threshold with \(r^2 < 0\), so it is demoted and the message saysdegenerate_fit. -
silent— the guard stays quiet. Fromamplitude=3.0, center=-3.9, sigma=0.8the fit settled at \(-0.082\), 2.7% of max |y|. It terminated normally, so the message is the ordinaryconverged_ftolandsuccessisTrue— with the same negative \(r^2\). -
recovered— a different solver, not a different guess. Handingsilent's exact starting point toFitOptions(solver="global")recoversamplitude=2.998, center=0.298, sigma=0.801against a planted truth of3.0, 0.3, 0.8. Local solvers descend from where they start; a bad enough start is a solver choice, not a data problem.
What to check on every fit¶
result.success— necessary, not sufficient.result.r_squared— a negative value means the model is doing worse than a horizontal line through the mean, whateversuccesssays.result.message— distinguishesconverged_ftolfrommax_iterations,no_improvement_possibleanddegenerate_fit. Note that a fit terminating on one of the two soft conditions with \(r^2 \ge 0.9\) is promoted tosuccess=Truewith the reason recorded in the message.- The recovered parameters themselves — a negative amplitude on a peak model, or a centre outside the measured range, is a wrong answer no scalar score will announce for you.
See also¶
- Related examples:
global_optimizer.md(the recovery used here, in its own right),bounded_fitting.md(constraining parameters so a fit cannot wander into this basin at all). - API docs:
FitResult.success,FitResult.message,FitResult.r_squared,FitOptions.solver.
