Selectable minimizers: Fumili, Levenberg-Marquardt and VarPro for the fit models (#387)
Build on RHEL9 / build (push) Successful in 3m1s
Build on RHEL8 / build (push) Successful in 3m40s
Run tests using data on local RHEL8 / build (push) Failing after 4m43s
Build on local RHEL8 / build (push) Successful in 3m22s

## Summary

Makes the minimizer behind `FitModel` selectable and adds two built-in,
dependency-free solvers next to Minuit2's Migrad and Fumili.
`Minimizer.Migrad` stays the default, so existing fits behave as before.

- **`Minimizer` enum** (C++ `FitModel::SetMinimizer`, Python `minimizer`
constructor argument and property): `Migrad`, `Fumili`,
`LevenbergMarquardt`, `VarPro`.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Khalil Ferjaoui <khalilferjaoui@yahoo.fr>
This commit is contained in:
Erik Fröjdh
2026-09-30 16:50:27 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Khalil Ferjaoui
parent 2efdd17d81
commit 9661aef013
26 changed files with 5459 additions and 838 deletions
+1
View File
@@ -40,6 +40,7 @@ from .Cluster import Cluster
from ._aare import Gaussian, RisingScurve, FallingScurve, Pol1, Pol2, GaussianErfcPlateau, GaussianChargeSharing, GaussianChargeSharingKb
from ._aare import fit
from ._aare import Minimizer
from ._aare import Interpolator
from ._aare import calculate_eta2, calculate_eta3, calculate_cross_eta3, calculate_full_eta2
from ._aare import reduce_to_2x2, reduce_to_3x3
+144 -87
View File
@@ -22,9 +22,40 @@ fit_dispatch(const aare::FitModel<Model> &model,
template <typename Model> void bind_fit_model(py::module &m, const char *name) {
using FM = aare::FitModel<Model>;
py::class_<FM>(m, name)
.def(py::init<unsigned int, unsigned int, double, bool>(),
.def(py::init<unsigned int, unsigned int, double, bool,
aare::Minimizer>(),
py::arg("strategy") = 0, py::arg("max_calls") = 100,
py::arg("tolerance") = 0.5, py::arg("compute_errors") = false)
py::arg("tolerance") = 0.5, py::arg("compute_errors") = false,
py::arg("minimizer") = aare::Minimizer::Migrad,
R"doc(
Fit configuration for this model.
Parameters
----------
strategy : int
Minuit2 strategy (0 = fast, 1 = Minuit2's default). Ignored
by ``Minimizer.LevenbergMarquardt`` and
``Minimizer.VarPro``.
max_calls : int
Work budget per pixel: Minuit2 function calls, or model
evaluations for ``Minimizer.LevenbergMarquardt`` and
``Minimizer.VarPro`` (one per iteration, two
when a step crosses a limit). 0 selects Minuit's default
budget.
tolerance : float
EDM tolerance in Minuit's convention. Migrad,
LevenbergMarquardt and VarPro stop below
``0.002 * tolerance``, Fumili below ``1e-4 * tolerance``.
compute_errors : bool
Also return parameter errors: from Hesse with Migrad, from
the linearised covariance with Fumili, LevenbergMarquardt
and VarPro. Fixed parameters and parameters
ending on a limit report 0.
minimizer : Minimizer
``Minimizer.Migrad`` (default), ``Minimizer.Fumili``,
``Minimizer.LevenbergMarquardt`` or
``Minimizer.VarPro``.
)doc")
.def("SetParLimits",
py::overload_cast<unsigned int, double, double>(&FM::SetParLimits),
py::arg("idx"), py::arg("lo"), py::arg("hi"))
@@ -59,6 +90,7 @@ template <typename Model> void bind_fit_model(py::module &m, const char *name) {
.def_property("tolerance", &FM::tolerance, &FM::SetTolerance)
.def_property("compute_errors", &FM::compute_errors,
&FM::SetComputeErrors)
.def_property("minimizer", &FM::minimizer, &FM::SetMinimizer)
.def(
"__call__", // conversion ok, we want to be able to call with any
// dtype
@@ -89,6 +121,11 @@ template <typename Model> void bind_fit_model(py::module &m, const char *name) {
R"doc(
Fit this model to 1D or 3D data using Minuit2.
The minimizer is selected by the ``minimizer`` property
(``Minimizer.Migrad`` by default, ``Minimizer.Fumili``,
``Minimizer.LevenbergMarquardt`` or
``Minimizer.VarPro``).
Parameters
----------
x : array_like, shape (n_scan,)
@@ -182,8 +219,23 @@ fit_dispatch(const aare::FitModel<Model> &model,
"chi2"_a = return_image_data(chi2_out));
}
} else {
// Unweighted fit; parameter errors are still available when
// requested, as in the 1D path.
NDView<double, 3> dummy_err{};
if (model.compute_errors()) {
auto err_out =
new NDArray<double, 3>({y.shape(0), y.shape(1), npar}, 0.0);
aare::fit_3d<Model>(model, x_view, y_view, dummy_err,
par_out->view(), err_out->view(),
chi2_out->view(), n_threads);
return py::dict("par"_a = return_image_data(par_out),
"par_err"_a = return_image_data(err_out),
"chi2"_a = return_image_data(chi2_out));
}
NDView<double, 3> dummy_err_out{};
aare::fit_3d<Model>(model, x_view, y_view, dummy_err,
@@ -217,7 +269,82 @@ fit_dispatch(const aare::FitModel<Model> &model,
}
}
// Resolve the model type behind a Python object and run the fit.
py::object dispatch_any_model(
py::object model_obj,
py::array_t<double, py::array::c_style | py::array::forcecast> x,
py::array_t<double, py::array::c_style | py::array::forcecast> y,
py::object y_err_obj, int n_threads) {
using namespace aare::model;
#define AARE_DISPATCH_MODEL(Model) \
if (py::isinstance<aare::FitModel<Model>>(model_obj)) { \
const auto &mdl = model_obj.cast<const aare::FitModel<Model> &>(); \
return fit_dispatch<Model>(mdl, x, y, y_err_obj, n_threads); \
}
AARE_DISPATCH_MODEL(Pol1)
AARE_DISPATCH_MODEL(Pol2)
AARE_DISPATCH_MODEL(Gaussian)
AARE_DISPATCH_MODEL(GaussianErfcPlateau)
AARE_DISPATCH_MODEL(GaussianChargeSharing)
AARE_DISPATCH_MODEL(GaussianChargeSharingKb)
AARE_DISPATCH_MODEL(RisingScurve)
AARE_DISPATCH_MODEL(FallingScurve)
#undef AARE_DISPATCH_MODEL
throw std::runtime_error(
"Unknown model type. Expected Pol1, Pol2, Gaussian, "
"GaussianErfcPlateau, GaussianChargeSharing, GaussianChargeSharingKb, "
"RisingScurve or FallingScurve.");
}
void define_fit_bindings(py::module &m) {
// The enum must exist before it is used as a constructor default below.
py::enum_<aare::Minimizer>(m, "Minimizer", R"doc(
Minimizer used by the fit models.
``Migrad`` (default) is Minuit2's variable-metric minimizer. With
``compute_errors`` its parameter errors come from Hesse.
``Fumili`` is Minuit2's Gauss-Newton minimizer with a trust region.
It takes the gradient and a linearised Hessian from the analytic
derivatives of the model and usually needs far fewer function
evaluations than Migrad. Its parameter errors come from the
covariance of the linearised Hessian, without an extra Hesse step.
Minuit2's Fumili cannot converge once a two-sided limit becomes
active; a pixel without a valid Fumili minimum is refitted with
Migrad.
``LevenbergMarquardt`` is the built-in damped Gauss-Newton solver. It
uses the analytic Jacobian, reflects steps at parameter limits and
reuses its buffers between pixels, so data cubes are fitted without
per-pixel allocations. Its parameter errors are
``sqrt(diag((J^T J)^-1))``; ``max_calls`` counts model evaluations
and ``strategy`` is ignored.
``VarPro`` is the built-in variable projection solver.
Every bundled model is linear in some of its parameters (the
amplitude of a Gaussian, the baseline and amplitude of an S-curve,
every parameter of a polynomial); each trial point solves those
exactly and the Levenberg-Marquardt iteration runs over the
remaining nonlinear parameters only. It needs fewer evaluations than
``LevenbergMarquardt`` and cannot be misled by poor start values of
the linear parameters, which it ignores. A pixel whose linear
solution violates a limit on a linear parameter, or that does not
converge, is refitted with ``LevenbergMarquardt``. Its parameter
errors are the same Gauss-Newton estimates; ``max_calls`` counts
evaluations and ``strategy`` is ignored.
With every minimizer, fixed parameters and parameters that end on a
limit report an error of 0 and a failed fit returns zeros.
)doc")
.value("Migrad", aare::Minimizer::Migrad)
.value("Fumili", aare::Minimizer::Fumili)
.value("LevenbergMarquardt", aare::Minimizer::LevenbergMarquardt)
.value("VarPro", aare::Minimizer::VarPro);
// ── Bind model classes ──────────────────────────────────────────
bind_fit_model<aare::model::Gaussian>(m, "Gaussian");
bind_fit_model<aare::model::GaussianErfcPlateau>(m, "GaussianErfcPlateau");
@@ -237,86 +364,19 @@ void define_fit_bindings(py::module &m) {
py::array_t<double, py::array::c_style | py::array::forcecast> x,
py::array_t<double, py::array::c_style | py::array::forcecast> y,
py::object y_err_obj, int n_threads) -> py::object {
using namespace aare::model;
// ── Polynomial of degree 1 ───────
if (py::isinstance<aare::FitModel<Pol1>>(model_obj)) {
const auto &mdl =
model_obj.cast<const aare::FitModel<Pol1> &>();
return fit_dispatch<Pol1>(mdl, x, y, y_err_obj, n_threads);
}
// ── Polynomial of degree 2 ───────
if (py::isinstance<aare::FitModel<Pol2>>(model_obj)) {
const auto &mdl =
model_obj.cast<const aare::FitModel<Pol2> &>();
return fit_dispatch<Pol2>(mdl, x, y, y_err_obj, n_threads);
}
// ── Gaussian ───────
if (py::isinstance<aare::FitModel<Gaussian>>(model_obj)) {
const auto &mdl =
model_obj.cast<const aare::FitModel<Gaussian> &>();
return fit_dispatch<Gaussian>(mdl, x, y, y_err_obj, n_threads);
}
// ── GaussianErfcPlateau ───────
if (py::isinstance<aare::FitModel<GaussianErfcPlateau>>(
model_obj)) {
const auto &mdl =
model_obj
.cast<const aare::FitModel<GaussianErfcPlateau> &>();
return fit_dispatch<GaussianErfcPlateau>(mdl, x, y, y_err_obj,
n_threads);
}
// ── GaussianChargeSharing ───────
if (py::isinstance<aare::FitModel<GaussianChargeSharing>>(
model_obj)) {
const auto &mdl =
model_obj
.cast<const aare::FitModel<GaussianChargeSharing> &>();
return fit_dispatch<GaussianChargeSharing>(mdl, x, y, y_err_obj,
n_threads);
}
// ── GaussianChargeSharingKb ───────
if (py::isinstance<aare::FitModel<GaussianChargeSharingKb>>(
model_obj)) {
const auto &mdl = model_obj.cast<
const aare::FitModel<GaussianChargeSharingKb> &>();
return fit_dispatch<GaussianChargeSharingKb>(
mdl, x, y, y_err_obj, n_threads);
}
// ── Rising Scurve ───────
if (py::isinstance<aare::FitModel<RisingScurve>>(model_obj)) {
const auto &mdl =
model_obj.cast<const aare::FitModel<RisingScurve> &>();
return fit_dispatch<RisingScurve>(mdl, x, y, y_err_obj,
n_threads);
}
// ── Falling Scurve ───────
if (py::isinstance<aare::FitModel<FallingScurve>>(model_obj)) {
const auto &mdl =
model_obj.cast<const aare::FitModel<FallingScurve> &>();
return fit_dispatch<FallingScurve>(mdl, x, y, y_err_obj,
n_threads);
}
throw std::runtime_error(
"Unknown model type. Expected Pol1, Pol2, Gaussian, "
"RisingScurve or FallingScurve.");
return dispatch_any_model(model_obj, x, y, y_err_obj, n_threads);
},
R"(
Fit a model to 1D or 3D data using Minuit2.
Fit a model to 1D or 3D data with the minimizer selected by the
model's ``minimizer`` property.
Parameters
----------
model : Pol1, Pol2, Gaussian, RisingScurve, or FallingScurve
Configured model object. User-set limits, fixed parameters,
and start values take precedence over automatic estimates.
model : object
Configured model object: Pol1, Pol2, Gaussian, GaussianErfcPlateau,
GaussianChargeSharing, GaussianChargeSharingKb, RisingScurve or
FallingScurve. User-set limits, fixed parameters, and start values
take precedence over automatic estimates.
x : array_like, shape (n_scan,)
Scan points (e.g. energy or threshold values).
y : array_like, shape (n_scan,) or (rows, cols, n_scan)
@@ -328,15 +388,12 @@ void define_fit_bindings(py::module &m) {
Returns
-------
For 1D input:
numpy array of shape (2*npar+1,) if compute_errors else (npar+1,).
Layout: [params..., (errors...,) chi2].
For 3D input:
dict with keys:
"par" : (rows, cols, npar) fitted parameters.
"par_err" : (rows, cols, npar) parameter errors (if compute_errors).
"chi2" : (rows, cols) chi-squared per pixel.
dict
``par`` holds the fitted parameters, shape (npar,) for 1D input or
(rows, cols, npar) for 3D input. ``chi2`` holds the chi-squared,
shape (1,) or (rows, cols). ``par_err`` holds the parameter
errors when ``compute_errors`` is enabled; fixed parameters and
parameters on a limit report 0. A failed fit returns zeros.
)",
py::arg("model"), py::arg("x"), py::arg("y"),
py::arg("y_err") = py::none(), py::arg("n_threads") = 4);
+388 -2
View File
@@ -1,15 +1,401 @@
# SPDX-License-Identifier: MPL-2.0
import numpy as np
import pytest
import aare
MINIMIZERS = [
aare.Minimizer.Migrad,
aare.Minimizer.Fumili,
aare.Minimizer.LevenbergMarquardt,
aare.Minimizer.VarPro,
]
def test_gaussian_model_evaluates_and_fits_data():
def gaussian(x, A, mu, sigma):
return A * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
def uses_minuit2(minimizer):
return minimizer not in (
aare.Minimizer.LevenbergMarquardt,
aare.Minimizer.VarPro,
)
@pytest.fixture(params=MINIMIZERS, ids=lambda m: m.name)
def minimizer(request):
return request.param
@pytest.fixture
def gaussian_data():
x = np.linspace(-6.0, 6.0, 61)
y = gaussian(x, 120.0, 0.8, 1.3)
y_err = np.ones_like(y)
return x, y, y_err
def test_gaussian_model_evaluates_and_fits_data(minimizer):
x = np.linspace(-5.0, 5.0, 51)
expected = np.array([20.0, 0.5, 1.2])
model = aare.Gaussian()
model = aare.Gaussian(minimizer=minimizer)
y = model(x, expected)
result = model.fit(x, y)
np.testing.assert_allclose(result["par"], expected, atol=2e-3)
def test_minimizer_can_be_selected():
assert aare.Gaussian().minimizer == aare.Minimizer.Migrad
assert set(aare.Minimizer.__members__) == {
"Migrad",
"Fumili",
"LevenbergMarquardt",
"VarPro",
}
for minimizer in MINIMIZERS:
assert aare.Gaussian(minimizer=minimizer).minimizer == minimizer
model = aare.Gaussian()
model.minimizer = aare.Minimizer.LevenbergMarquardt
assert model.minimizer == aare.Minimizer.LevenbergMarquardt
def test_fit_returns_errors_and_chi2(gaussian_data, minimizer):
x, y, y_err = gaussian_data
model = aare.Gaussian(compute_errors=True, minimizer=minimizer)
result = model.fit(x, y, y_err)
assert set(result) == {"par", "par_err", "chi2"}
assert result["par"].shape == (3,)
assert result["par_err"].shape == (3,)
assert result["chi2"].shape == (1,)
np.testing.assert_allclose(result["par"], [120.0, 0.8, 1.3], rtol=1e-4)
np.testing.assert_allclose(
result["par_err"], [0.361, 0.00451, 0.00451], rtol=2e-2
)
assert result["chi2"][0] == pytest.approx(0.0, abs=1e-3)
without_errors = aare.fit(aare.Gaussian(minimizer=minimizer), x, y)
assert set(without_errors) == {"par", "chi2"}
def test_pol1_weighted_errors_match_analytic_formula(minimizer):
x = np.arange(20, dtype=float)
y = 2.0 + 0.5 * x
y_err = 1.0 + 0.1 * x
w = 1.0 / y_err**2
S, Sx, Sxx = w.sum(), (w * x).sum(), (w * x * x).sum()
delta = S * Sxx - Sx * Sx
result = aare.Pol1(compute_errors=True, minimizer=minimizer).fit(x, y, y_err)
np.testing.assert_allclose(result["par"], [2.0, 0.5], rtol=1e-6)
np.testing.assert_allclose(
result["par_err"],
[np.sqrt(Sxx / delta), np.sqrt(S / delta)],
rtol=1e-6,
)
def test_fixed_parameter_reports_zero_error(gaussian_data, minimizer):
x, y, y_err = gaussian_data
model = aare.Gaussian(compute_errors=True, minimizer=minimizer)
model.FixParameter("sigma", 1.5)
result = model.fit(x, y, y_err)
assert result["par"][2] == 1.5
assert result["par_err"][2] == 0.0
assert result["par"][0] == pytest.approx(111.15, rel=1e-3)
assert result["chi2"][0] == pytest.approx(1684.25, rel=1e-3)
model.ReleaseParameter("sigma")
result = model.fit(x, y, y_err)
assert result["par"][2] == pytest.approx(1.3, rel=1e-4)
def test_active_limit_ends_on_the_limit(gaussian_data, minimizer):
x, y, y_err = gaussian_data
model = aare.Gaussian(compute_errors=True, minimizer=minimizer)
model.SetParLimits("mu", -5.0, 0.5)
result = model.fit(x, y, y_err)
margin = 1e-6 if uses_minuit2(minimizer) else 1e-9
assert result["par"][1] == pytest.approx(0.5, abs=margin)
assert result["par_err"][1] == 0.0
assert result["par"][0] == pytest.approx(116.88, rel=1e-3)
assert result["chi2"][0] == pytest.approx(4301.95, rel=1e-3)
inactive = aare.Gaussian(minimizer=minimizer)
inactive.SetParLimits(1, -5.0, 5.0)
np.testing.assert_allclose(
inactive.fit(x, y)["par"], [120.0, 0.8, 1.3], rtol=1e-4
)
def test_user_start_value(gaussian_data, minimizer):
x, y, _ = gaussian_data
model = aare.Gaussian(minimizer=minimizer)
model.SetParameter("mu", 0.0)
np.testing.assert_allclose(model.fit(x, y)["par"], [120.0, 0.8, 1.3], rtol=1e-4)
def test_max_calls_failure_returns_zeros(gaussian_data, minimizer):
x, y, y_err = gaussian_data
model = aare.Gaussian(max_calls=3, compute_errors=True, minimizer=minimizer)
# start far from the minimum so that no minimizer converges in 3 calls
model.SetParameter("mu", -3.0)
model.SetParameter("sigma", 3.0)
result = model.fit(x, y, y_err)
assert np.all(result["par"] == 0.0)
assert np.all(result["par_err"] == 0.0)
assert np.all(result["chi2"] == 0.0)
def test_strategy_is_ignored_by_levenberg_marquardt(gaussian_data):
x, y, _ = gaussian_data
lm = aare.Minimizer.LevenbergMarquardt
a = aare.Gaussian(strategy=0, minimizer=lm).fit(x, y)
b = aare.Gaussian(strategy=1, minimizer=lm).fit(x, y)
np.testing.assert_array_equal(a["par"], b["par"])
def test_invalid_parameter_access_raises():
model = aare.Gaussian()
assert model.par_names == ["A", "mu", "sigma"]
assert model.n_par == 3
with pytest.raises(RuntimeError):
model.SetParameter("nope", 1.0)
with pytest.raises(IndexError):
model.SetParameter(3, 1.0)
with pytest.raises(RuntimeError):
model.SetParLimits("mu", 1.0, 1.0)
def test_mismatched_array_sizes_raise(gaussian_data, minimizer):
x, y, y_err = gaussian_data
model = aare.Gaussian(minimizer=minimizer)
with pytest.raises(RuntimeError):
model.fit(x, y[:-1])
with pytest.raises(RuntimeError):
model.fit(x, y, y_err[:-1])
@pytest.mark.parametrize("cls", [aare.RisingScurve, aare.FallingScurve])
def test_scurve_fit(cls, minimizer):
truth = np.array([10.0, 0.1, 50.0, 3.0, 1000.0, 2.0])
x = np.linspace(0.0, 100.0, 100)
model = cls(max_calls=500, compute_errors=True, minimizer=minimizer)
y = model(x, truth)
result = model.fit(x, y, np.ones_like(y))
np.testing.assert_allclose(result["par"], truth, rtol=1e-3)
assert np.all(result["par_err"] > 0.0)
@pytest.mark.parametrize("with_errors", [False, True])
@pytest.mark.parametrize("weighted", [False, True])
def test_fit_3d_matches_fit_1d(with_errors, weighted, minimizer):
x = np.linspace(-6.0, 6.0, 61)
rows, cols = 4, 3
y = np.empty((rows, cols, x.size))
for r in range(rows):
for c in range(cols):
y[r, c] = gaussian(x, 80.0 + 10.0 * r, -0.6 + 0.3 * c, 0.9 + 0.1 * r)
y_err = np.ones_like(y) if weighted else None
model = aare.Gaussian(compute_errors=with_errors, minimizer=minimizer)
cube = model.fit(x, y, y_err, n_threads=3)
expected_keys = {"par", "par_err", "chi2"} if with_errors else {"par", "chi2"}
assert set(cube) == expected_keys
assert cube["par"].shape == (rows, cols, 3)
assert cube["chi2"].shape == (rows, cols)
if with_errors:
assert cube["par_err"].shape == (rows, cols, 3)
for r in range(rows):
for c in range(cols):
single = model.fit(x, y[r, c], None if y_err is None else y_err[r, c])
np.testing.assert_allclose(cube["par"][r, c], single["par"], rtol=1e-10)
assert cube["chi2"][r, c] == pytest.approx(single["chi2"][0], abs=1e-10)
if with_errors:
np.testing.assert_allclose(
cube["par_err"][r, c], single["par_err"], rtol=1e-10
)
np.testing.assert_allclose(cube["par"][3, 2], [110.0, 0.0, 1.2], rtol=1e-4, atol=1e-6)
def test_all_minimizers_agree_on_3d_data_with_errors():
x = np.linspace(-5.0, 5.0, 51)
expected = np.array([20.0, 0.5, 1.2])
single = aare.Gaussian()(x, expected)
y = np.tile(single, (2, 3, 1))
y_err = np.ones_like(y)
results = {
minimizer: aare.Gaussian(compute_errors=True, minimizer=minimizer).fit(
x, y, y_err, n_threads=2
)
for minimizer in MINIMIZERS
}
reference = results[aare.Minimizer.Migrad]
for minimizer, result in results.items():
assert result["par"].shape == (2, 3, 3)
assert result["par_err"].shape == (2, 3, 3)
np.testing.assert_allclose(result["par"], np.broadcast_to(expected, (2, 3, 3)), atol=2e-3)
np.testing.assert_allclose(result["chi2"], 0.0, atol=1e-3)
np.testing.assert_allclose(result["par_err"], reference["par_err"], rtol=2e-2)
# Regressions of the built-in solvers: fits that used to be reported as valid
# although they had stopped away from the minimum or returned garbage.
def test_gaussian_fit_to_noise_fails_or_stays_bounded(minimizer):
# A width collapsing between two scan points let the amplitude diverge
# (up to 1e159) in a fit that was still reported as valid.
x = np.linspace(-6.0, 6.0, 61)
y = np.random.default_rng(0).normal(0.0, 1.0, (20, 20, x.size))
result = aare.Gaussian(minimizer=minimizer).fit(x, y, n_threads=2)
par = result["par"]
failed = np.all(par == 0.0, axis=-1)
bounded = np.all(np.isfinite(par) & (np.abs(par) < 1e6), axis=-1)
assert np.all(failed | bounded)
assert np.all(np.isfinite(result["chi2"]))
def test_step_reflected_onto_start_is_not_convergence(minimizer):
# The step of +2 reflects at the limit 1 back onto the start 0; the
# truncated step must still be tried.
model = aare.Pol1(minimizer=minimizer)
model.SetParameter("p0", 0.0)
model.FixParameter("p1", 0.0)
model.SetParLimits("p0", -1.0, 1.0)
result = model.fit(np.arange(4.0), np.full(4, 2.2))
assert result["par"][0] == pytest.approx(1.0, abs=1e-6)
assert result["chi2"][0] == pytest.approx(5.76, rel=1e-6)
@pytest.mark.parametrize(
"amplitude, offset", [(1e8, 0.0), (1e10, 0.0), (120.0, 1e9)]
)
def test_large_parameter_does_not_stop_the_fit(minimizer, amplitude, offset):
# One relative step norm over all parameters let a large amplitude or
# position stop the fit at the start estimate of the width.
x = np.linspace(-6.0, 6.0, 61)
y = gaussian(x, amplitude, 0.8, 1.3)
y_err = np.sqrt(y) + 1.0
result = aare.Gaussian(minimizer=minimizer, max_calls=500).fit(
x + offset, y, y_err
)
par = result["par"]
assert par[0] == pytest.approx(amplitude, rel=1e-3)
assert par[1] - offset == pytest.approx(0.8, abs=1e-3)
assert par[2] == pytest.approx(1.3, rel=1e-3)
def test_rejected_trial_on_limit_does_not_freeze_interior_parameter(minimizer):
# A rejected trial truncated onto the lower limit froze mu at its start.
x = np.linspace(-0.1, 0.1, 11)
mu = np.sqrt(2.0 * np.log(2.0))
y = gaussian(x, 10.0, mu, 1.0)
model = aare.Gaussian(minimizer=minimizer)
model.FixParameter("A", 10.0)
model.FixParameter("sigma", 1.0)
model.SetParameter("mu", 3.0)
model.SetParLimits("mu", 0.0, 5.0)
result = model.fit(x, y)
assert result["par"][1] == pytest.approx(mu, abs=1e-3)
assert result["chi2"][0] < 1e-4
# Samples with a zero error are ignored, also when they hold NaN (a masked
# dead frame): a zero weight does not remove a NaN from a sum.
@pytest.mark.parametrize("masked", [4, 0, 9])
def test_masked_nan_sample_is_ignored(minimizer, masked):
# The start estimate of Pol1 uses the first and the last sample.
x = np.arange(10.0)
y = 2.0 + 0.5 * x
y_err = np.ones_like(y)
y[masked] = np.nan
y_err[masked] = 0.0
result = aare.Pol1(minimizer=minimizer, compute_errors=True).fit(x, y, y_err)
np.testing.assert_allclose(result["par"], [2.0, 0.5], atol=1e-5)
assert np.all(result["par_err"] > 0.0)
assert result["chi2"][0] == pytest.approx(0.0, abs=1e-6)
def test_too_few_unmasked_samples_fail(minimizer):
x = np.arange(10.0)
y = np.full_like(x, np.nan)
y_err = np.zeros_like(x)
y[0], y_err[0] = 2.0, 1.0 # one sample left for two free parameters
result = aare.Pol1(minimizer=minimizer).fit(x, y, y_err)
assert np.all(result["par"] == 0.0)
assert np.all(result["chi2"] == 0.0)
def test_masked_samples_in_cube_match_1d_fits(minimizer):
x = np.linspace(-6.0, 6.0, 61)
y = np.stack([gaussian(x, 80.0 + 10.0 * c, 0.8, 1.2) for c in range(4)])
y = y.reshape(1, 4, x.size)
y_err = np.ones_like(y)
for col, masked in [(1, 10), (2, 0), (3, 34)]:
y[0, col, masked] = np.nan
y_err[0, col, masked] = 0.0
model = aare.Gaussian(minimizer=minimizer, compute_errors=True)
cube = model.fit(x, y, y_err, n_threads=1)
for col in range(4):
single = model.fit(x, y[0, col], y_err[0, col])
np.testing.assert_allclose(cube["par"][0, col], single["par"], atol=1e-12)
np.testing.assert_allclose(cube["par_err"][0, col], single["par_err"], atol=1e-12)
np.testing.assert_allclose(
cube["par"][0, col], [80.0 + 10.0 * col, 0.8, 1.2], rtol=1e-3
)
def test_parameter_whose_minimum_lies_on_its_limit_reports_no_error(minimizer):
# At a minimum that lies exactly on the limit the gradient is zero.
x = np.array([-1.0, -1.0, 1.0, 1.0])
model = aare.Pol1(minimizer=minimizer, compute_errors=True)
model.SetParLimits("p0", 0.0, 1.0)
result = model.fit(x, 1.0 + 2.0 * x)
assert result["par"][0] == pytest.approx(1.0, abs=1e-6)
assert result["par_err"][0] == 0.0
assert result["par_err"][1] == pytest.approx(0.5, rel=1e-6)
@pytest.mark.parametrize("amplitude", [0.1, 1e-4])
def test_fit_does_not_stop_at_start_without_a_step(minimizer, amplitude):
# The EDM tolerance is absolute in chi2 units: an unweighted fit of small
# data passes it at the start estimate (sigma 1.3617) already.
x = np.linspace(-6.0, 6.0, 61)
y = gaussian(x, amplitude, 0.8, 1.3)
result = aare.Gaussian(minimizer=minimizer).fit(x, y)
assert abs(result["par"][2] - 1.3) < 0.03 # Migrad ends at 1.3205
one_call = aare.Gaussian(minimizer=minimizer, max_calls=1).fit(x, y)
assert np.all(one_call["par"] == 0.0)
@pytest.mark.parametrize("lo, span", [(8000, 1000), (20000, 500), (60000, 200)])
def test_polynomial_far_from_zero_keeps_its_precision(minimizer, lo, span):
# Nearly collinear columns 1, x, x^2: VarPro's normal equations lost six
# digits at x = 20000.
x = np.linspace(lo, lo + span, 40)
c = np.array([1.5 - 2e-3 * lo + 3e-7 * lo**2, 2e-3 - 6e-7 * lo, 3e-7])
y = c[0] + c[1] * x + c[2] * x**2
result = aare.Pol2(minimizer=minimizer).fit(x, y)
np.testing.assert_allclose(result["par"], c, rtol=1e-9)