mirror of
https://github.com/slsdetectorgroup/aare.git
synced 2026-10-17 10:05:10 +02:00
Selectable minimizers: Fumili, Levenberg-Marquardt and VarPro for the fit models (#387)
## 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:
co-authored by
Claude Fable 5.1
Khalil Ferjaoui
parent
2efdd17d81
commit
9661aef013
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user