// SPDX-License-Identifier: MPL-2.0 #include "aare/Fit.hpp" #include "aare/FitModel.hpp" #include "aare/Models.hpp" #include #include #include #include #include #include #include #include #include using aare::Minimizer; using aare::NDArray; using aare::NDView; using Catch::Approx; namespace { constexpr ssize_t n_points = 61; struct Data { NDArray x; NDArray y; NDArray y_err; }; void fill_gaussian(NDArray &x, NDArray &y, double amplitude, double mean, double sigma) { for (ssize_t i = 0; i < n_points; ++i) { x(i) = -6.0 + 0.2 * static_cast(i); const double z = (x(i) - mean) / sigma; y(i) = amplitude * std::exp(-0.5 * z * z); } } // Gaussian with A = 120, mu = 0.8, sigma = 1.3 and unit errors, optionally // with deterministic pseudo-noise. Data gaussian_data(bool add_noise = false) { Data d{NDArray({n_points}), NDArray({n_points}), NDArray({n_points}, 1.0)}; fill_gaussian(d.x, d.y, 120.0, 0.8, 1.3); if (add_noise) { for (ssize_t i = 0; i < n_points; ++i) d.y(i) += 3.0 * std::sin(7.3 * static_cast(i)); } return d; } // S-curve on 100 points in [0, 100] with unit errors, optionally with // deterministic pseudo-noise and errors of 30. template Data scurve_data(const std::vector &par, bool add_noise = false) { constexpr ssize_t n = 100; Data d{NDArray({n}), NDArray({n}), NDArray({n}, 1.0)}; for (ssize_t i = 0; i < n; ++i) { d.x(i) = 100.0 * static_cast(i) / static_cast(n - 1); d.y(i) = Model::eval(d.x(i), par); if (add_noise) { d.y(i) += 30.0 * std::sin(7.3 * static_cast(i)); d.y_err(i) = 30.0; } } return d; } const char *name(Minimizer m) { switch (m) { case Minimizer::Migrad: return "Migrad"; case Minimizer::Fumili: return "Fumili"; case Minimizer::LevenbergMarquardt: return "LevenbergMarquardt"; case Minimizer::VarPro: return "VarPro"; } return "unknown"; } bool uses_minuit2(Minimizer m) { return m != Minimizer::LevenbergMarquardt && m != Minimizer::VarPro; } template aare::FitModel make_model(Minimizer minimizer, bool errors = false, unsigned int max_calls = 100) { return aare::FitModel(0, max_calls, 0.5, errors, minimizer); } // Minuit2 reaches an active limit through its parameter transformation; // the built-in solver lands on it exactly. double limit_margin(Minimizer m) { return uses_minuit2(m) ? 1e-6 : 1e-9; } // Relative agreement with the true parameters: the Minuit2 minimizers stop // at Minuit's EDM tolerance, the built-in solver lands tighter. double truth_tolerance(Minimizer m) { return uses_minuit2(m) ? 1e-3 : 1e-4; } // Hesse errors at a point forced away from the free minimum differ from the // Gauss-Newton estimates of Fumili and the built-in solver. double misfit_error_tolerance(Minimizer m) { return uses_minuit2(m) ? 0.1 : 5e-2; } template bool on_limit(const aare::FitModel &model, unsigned int idx, double value) { const double margin = 1e-6 * std::max(1.0, std::abs(value)); return std::abs(value - model.lower_limit(idx)) <= margin || std::abs(value - model.upper_limit(idx)) <= margin; } /** * Run one configuration through every minimizer and compare Fumili and the * built-in solver against Migrad: parameters and chi2 within tol (relative), * unless the candidate found a clearly lower chi2. Errors are compared for * free parameters that do not end on a limit with tolerance err_tol. */ template void check_minimizers_agree(const std::string &label, Data d, bool weighted, bool errors, unsigned int max_calls, double tol, Setup setup, double err_tol = 0.0) { if (err_tol == 0.0) err_tol = 10.0 * tol; constexpr std::size_t npar = Model::npar; const auto run = [&](Minimizer minimizer) { aare::FitModel model(0, max_calls, 0.5, errors, minimizer); setup(model); return weighted ? aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()) : aare::fit_pixel(model, d.x.view(), d.y.view()); }; aare::FitModel reference_model(0, max_calls, 0.5, errors, Minimizer::Migrad); setup(reference_model); const auto ref = run(Minimizer::Migrad); for (const auto candidate : {Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro}) { INFO(label << ": " << name(candidate) << " vs Migrad"); const auto res = run(candidate); REQUIRE(res.size() == ref.size()); const auto ichi = static_cast(errors ? 2 * npar : npar); const double chi_ref = ref(ichi); const double chi_res = res(ichi); if (chi_ref > 0.0 && chi_res > 0.0 && chi_ref - chi_res > tol * std::max(chi_ref, 1.0)) { WARN(label + ": " + name(candidate) + " found a lower chi2 (" + std::to_string(chi_res) + " vs " + std::to_string(chi_ref) + ")"); continue; } double scale = 0.0; for (std::size_t k = 0; k < npar; ++k) scale = std::max(scale, std::abs(ref(static_cast(k)))); for (std::size_t k = 0; k < npar; ++k) { const auto i = static_cast(k); const double denom = std::max({std::abs(ref(i)), 1e-2 * scale, 1e-12}); INFO("parameter " << reference_model.GetParName(static_cast(k))); CHECK(std::abs(res(i) - ref(i)) / denom <= tol); } CHECK(std::abs(chi_res - chi_ref) / std::max(std::abs(chi_ref), 1.0) <= tol); if (!errors) continue; for (std::size_t k = 0; k < npar; ++k) { const auto idx = static_cast(k); const auto i = static_cast(k); if (reference_model.is_user_fixed(idx) || on_limit(reference_model, idx, ref(i))) continue; const auto ie = static_cast(npar + k); INFO("error of " << reference_model.GetParName(idx)); CHECK(std::abs(res(ie) - ref(ie)) / std::max(std::abs(ref(ie)), 1e-12) <= err_tol); } } } } // namespace TEST_CASE("Fit unweighted and weighted noise-free Gaussian data", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); auto d = gaussian_data(); const auto unweighted_model = make_model(minimizer); const auto unweighted = aare::fit_pixel(unweighted_model, d.x.view(), d.y.view()); REQUIRE(unweighted.size() == 4); CHECK(unweighted(0) == Approx(120.0).epsilon(1e-4).margin(1e-6)); CHECK(unweighted(1) == Approx(0.8).epsilon(1e-4).margin(1e-6)); CHECK(unweighted(2) == Approx(1.3).epsilon(1e-4).margin(1e-6)); CHECK(unweighted(3) == Approx(0.0).margin(1e-3)); const auto weighted_model = make_model(minimizer, true); const auto weighted = aare::fit_pixel(weighted_model, d.x.view(), d.y.view(), d.y_err.view()); REQUIRE(weighted.size() == 7); CHECK(weighted(0) == Approx(120.0).epsilon(1e-4).margin(1e-6)); CHECK(weighted(1) == Approx(0.8).epsilon(1e-4).margin(1e-6)); CHECK(weighted(2) == Approx(1.3).epsilon(1e-4).margin(1e-6)); // At zero residual the Hesse, Fumili and Gauss-Newton errors coincide. CHECK(weighted(3) == Approx(0.361).epsilon(2e-2)); CHECK(weighted(4) == Approx(0.00451).epsilon(2e-2)); CHECK(weighted(5) == Approx(0.00451).epsilon(2e-2)); CHECK(weighted(6) == Approx(0.0).margin(1e-3)); } TEST_CASE("Fit weighted Pol1 errors match the analytic formula", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); constexpr ssize_t n = 20; NDArray x({n}); NDArray y({n}); NDArray y_err({n}); double S = 0.0, Sx = 0.0, Sxx = 0.0; for (ssize_t i = 0; i < n; ++i) { x(i) = static_cast(i); y(i) = 2.0 + 0.5 * x(i); y_err(i) = 1.0 + 0.1 * static_cast(i); const double w = 1.0 / (y_err(i) * y_err(i)); S += w; Sx += w * x(i); Sxx += w * x(i) * x(i); } const double delta = S * Sxx - Sx * Sx; const auto model = make_model(minimizer, true); const auto res = aare::fit_pixel(model, x.view(), y.view(), y_err.view()); REQUIRE(res.size() == 5); CHECK(res(0) == Approx(2.0).epsilon(1e-6)); CHECK(res(1) == Approx(0.5).epsilon(1e-6)); CHECK(res(2) == Approx(std::sqrt(Sxx / delta)).epsilon(1e-6)); CHECK(res(3) == Approx(std::sqrt(S / delta)).epsilon(1e-6)); CHECK(res(4) == Approx(0.0).margin(1e-8)); } TEST_CASE("Fit with a fixed parameter", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); auto d = gaussian_data(); SECTION("fixed at a wrong value") { auto model = make_model(minimizer, true); model.FixParameter("sigma", 1.5); REQUIRE(model.is_user_fixed(2)); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()); REQUIRE(res.size() == 7); CHECK(res(2) == 1.5); CHECK(res(0) == Approx(111.15).epsilon(1e-3)); CHECK(res(1) == Approx(0.8).epsilon(1e-3)); CHECK(res(3) == Approx(0.274).epsilon(misfit_error_tolerance(minimizer))); CHECK(res(5) == 0.0); // fixed parameters report no error CHECK(res(6) == Approx(1684.25).epsilon(1e-3)); } SECTION("fixed at the true value, then released") { auto model = make_model(minimizer, true); model.FixParameter(2, 1.3); auto res = aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()); CHECK(res(0) == Approx(120.0).epsilon(1e-4).margin(1e-6)); CHECK(res(1) == Approx(0.8).epsilon(1e-4).margin(1e-6)); CHECK(res(2) == 1.3); CHECK(res(5) == 0.0); CHECK(res(6) == Approx(0.0).margin(1e-3)); model.ReleaseParameter("sigma"); REQUIRE_FALSE(model.is_user_fixed(2)); res = aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()); CHECK(res(2) == Approx(1.3).epsilon(1e-4).margin(1e-6)); CHECK(res(5) == Approx(0.00451).epsilon(2e-2)); } } TEST_CASE("Fit with parameter limits", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); auto d = gaussian_data(); const double margin = limit_margin(minimizer); const double err_tol = misfit_error_tolerance(minimizer); SECTION("active upper limit on mu") { auto model = make_model(minimizer); model.SetParLimits("mu", -5.0, 0.5); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view()); CHECK(res(1) == Approx(0.5).margin(margin)); CHECK(res(0) == Approx(116.88).epsilon(1e-3)); CHECK(res(2) == Approx(1.335).epsilon(1e-3)); CHECK(res(3) == Approx(4301.95).epsilon(1e-3)); } SECTION("active upper limit on sigma, with errors") { auto model = make_model(minimizer, true); model.SetParLimits(2, 0.5, 1.0); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()); CHECK(res(2) == Approx(1.0).margin(margin)); CHECK(res(1) == Approx(0.8).epsilon(1e-3)); CHECK(res(3) == Approx(0.336).epsilon(err_tol)); CHECK(res(5) == 0.0); // on a limit: no error reported CHECK(res(6) == Approx(5550.61).epsilon(1e-3)); } SECTION("fixed amplitude and an active limit on mu") { auto model = make_model(minimizer, true); model.FixParameter("A", 100.0); model.SetParLimits("mu", 0.6, 0.7); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()); CHECK(res(0) == 100.0); CHECK(res(1) == Approx(0.7).margin(margin)); CHECK(res(2) == Approx(1.4603).epsilon(1e-3)); CHECK(res(3) == 0.0); CHECK(res(4) == 0.0); CHECK(res(5) == Approx(0.00469).epsilon(err_tol)); CHECK(res(6) == Approx(3631.01).epsilon(1e-3)); } SECTION("inactive limits do not change the result") { const auto free_model = make_model(minimizer); const auto reference = aare::fit_pixel(free_model, d.x.view(), d.y.view()); auto model = make_model(minimizer); model.SetParLimits("mu", -5.0, 5.0); model.SetParLimits("sigma", 0.5, std::numeric_limits::infinity()); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view()); for (ssize_t k = 0; k < 3; ++k) CHECK(res(k) == Approx(reference(k)).epsilon(1e-4).margin(1e-6)); } } TEST_CASE("Fit honours user start values", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); auto d = gaussian_data(); SECTION("start value for mu") { auto model = make_model(minimizer); model.SetParameter("mu", 0.0); REQUIRE(model.is_user_start(1)); CHECK(model.value(1) == 0.0); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view()); CHECK(res(0) == Approx(120.0).epsilon(1e-4).margin(1e-6)); CHECK(res(1) == Approx(0.8).epsilon(1e-4).margin(1e-6)); CHECK(res(2) == Approx(1.3).epsilon(1e-4).margin(1e-6)); } SECTION("negative Gaussian with all start values given") { Data n{NDArray({n_points}), NDArray({n_points}), NDArray({n_points}, 1.0)}; fill_gaussian(n.x, n.y, -120.0, 0.8, 1.3); auto model = make_model(minimizer); model.SetParameter("A", -100.0); model.SetParameter("mu", 0.5); model.SetParameter("sigma", 1.0); const auto res = aare::fit_pixel(model, n.x.view(), n.y.view()); CHECK(res(0) == Approx(-120.0).epsilon(1e-4).margin(1e-6)); CHECK(res(1) == Approx(0.8).epsilon(1e-4).margin(1e-6)); CHECK(res(2) == Approx(1.3).epsilon(1e-4).margin(1e-6)); } } TEST_CASE("Fit reports failure as zeros when max_calls is exhausted", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); auto d = gaussian_data(); // Start far from the minimum so that no minimizer converges in 3 calls. auto model = make_model(minimizer, false, 3); model.SetParameter("mu", -3.0); model.SetParameter("sigma", 3.0); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view()); REQUIRE(res.size() == 4); for (ssize_t k = 0; k < res.size(); ++k) CHECK(res(k) == 0.0); auto model_err = make_model(minimizer, true, 3); model_err.SetParameter("mu", -3.0); model_err.SetParameter("sigma", 3.0); const auto res_err = aare::fit_pixel(model_err, d.x.view(), d.y.view(), d.y_err.view()); REQUIRE(res_err.size() == 7); for (ssize_t k = 0; k < res_err.size(); ++k) CHECK(res_err(k) == 0.0); } TEST_CASE("FitModel validates parameter names, indices and limits", "[fit]") { aare::FitModel model; CHECK(model.GetParNames() == std::vector{"A", "mu", "sigma"}); CHECK(model.GetParName(2) == "sigma"); CHECK(model.GetNpar() == 3); CHECK(model.strategy() == 0); CHECK(model.minimizer() == Minimizer::Migrad); CHECK(model.lower_limit(2) == 1e-12); CHECK(std::isinf(model.upper_limit(2))); CHECK_THROWS_AS(model.SetParameter("nope", 1.0), std::runtime_error); CHECK_THROWS_AS(model.SetParameter(3, 1.0), std::out_of_range); CHECK_THROWS_AS(model.GetParName(5), std::out_of_range); CHECK_THROWS_AS(model.SetParLimits(0, 1.0, 1.0), std::runtime_error); CHECK_THROWS_AS(model.FixParameter("nope", 1.0), std::runtime_error); CHECK_NOTHROW(model.SetParLimits("mu", -1.0, 1.0)); CHECK(model.lower_limit(1) == -1.0); CHECK(model.upper_limit(1) == 1.0); model.SetMinimizer(Minimizer::LevenbergMarquardt); CHECK(model.minimizer() == Minimizer::LevenbergMarquardt); } TEST_CASE("Copying a FitModel keeps the minimizer", "[fit]") { const aare::FitModel original( 0, 100, 0.5, false, Minimizer::LevenbergMarquardt); const aare::FitModel copied(original); aare::FitModel assigned; assigned = original; CHECK(copied.minimizer() == Minimizer::LevenbergMarquardt); CHECK(assigned.minimizer() == Minimizer::LevenbergMarquardt); } TEST_CASE("Fit rising and falling S-curves", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); const std::vector truth{10.0, 0.1, 50.0, 3.0, 1000.0, 2.0}; SECTION("rising, free fit with errors") { auto d = scurve_data(truth); const auto model = make_model(minimizer, true, 500); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()); REQUIRE(res.size() == 13); for (std::size_t k = 0; k < truth.size(); ++k) { CHECK(res(static_cast(k)) == Approx(truth[k]).epsilon(1e-3)); CHECK(res(static_cast(6 + k)) > 0.0); } CHECK(res(12) == Approx(0.0).margin(1e-3)); } SECTION("rising, fixed slope and limited width") { auto d = scurve_data(truth); auto model = make_model(minimizer, true, 500); model.FixParameter("p1", 0.1); model.SetParLimits("sigma", 1.0, 4.0); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view(), d.y_err.view()); CHECK(res(1) == 0.1); CHECK(res(7) == 0.0); CHECK(res(2) == Approx(50.0).epsilon(1e-3)); CHECK(res(3) == Approx(3.0).epsilon(1e-3)); CHECK(res(9) > 0.0); } SECTION("rising, active limit on the threshold") { auto d = scurve_data(truth); auto model = make_model(minimizer, false, 500); model.SetParLimits("mu", 40.0, 48.0); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view()); CHECK(res(2) == Approx(48.0).margin(limit_margin(minimizer))); CHECK(res(6) > 0.0); // chi2 of the constrained fit } SECTION("falling") { auto d = scurve_data(truth); const auto model = make_model(minimizer, false, 500); const auto res = aare::fit_pixel(model, d.x.view(), d.y.view()); REQUIRE(res.size() == 7); for (std::size_t k = 0; k < truth.size(); ++k) CHECK(res(static_cast(k)) == Approx(truth[k]).epsilon(1e-3)); } } TEST_CASE("Fit a Gaussian data cube in parallel", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); NDArray x({n_points}); NDArray values({n_points}); fill_gaussian(x, values, 80.0, -0.6, 0.9); NDArray y({2, 2, n_points}); for (ssize_t row = 0; row < 2; ++row) { for (ssize_t col = 0; col < 2; ++col) { for (ssize_t i = 0; i < n_points; ++i) y(row, col, i) = values(i); } } NDArray par({2, 2, 3}); NDArray chi2({2, 2}); const auto model = make_model(minimizer); aare::fit_3d(model, x.view(), y.view(), NDView{}, par.view(), NDView{}, chi2.view(), 2); for (ssize_t row = 0; row < 2; ++row) { for (ssize_t col = 0; col < 2; ++col) { CHECK(par(row, col, 0) == Approx(80.0).epsilon(1e-4).margin(1e-6)); CHECK(par(row, col, 1) == Approx(-0.6).epsilon(1e-4).margin(1e-6)); CHECK(par(row, col, 2) == Approx(0.9).epsilon(1e-4).margin(1e-6)); CHECK(chi2(row, col) == Approx(0.0).margin(1e-3)); } } CHECK_THROWS_AS(aare::fit_3d(model, NDView(x.data(), {10}), y.view(), NDView{}, par.view(), NDView{}, chi2.view(), 2), std::runtime_error); } TEST_CASE("Fit a data cube with errors matches per-pixel fits", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); constexpr ssize_t rows = 4; constexpr ssize_t cols = 3; NDArray x({n_points}); NDArray y({rows, cols, n_points}); NDArray y_err({rows, cols, n_points}, 1.0); NDArray tmp({n_points}); for (ssize_t row = 0; row < rows; ++row) { for (ssize_t col = 0; col < cols; ++col) { fill_gaussian(x, tmp, 80.0 + 10.0 * static_cast(row), -0.6 + 0.3 * static_cast(col), 0.9 + 0.1 * static_cast(row)); for (ssize_t i = 0; i < n_points; ++i) y(row, col, i) = tmp(i); } } NDArray par({rows, cols, 3}); NDArray err({rows, cols, 3}); NDArray chi2({rows, cols}); const auto model = make_model(minimizer, true); aare::fit_3d(model, x.view(), y.view(), y_err.view(), par.view(), err.view(), chi2.view(), 3); for (ssize_t row = 0; row < rows; ++row) { for (ssize_t col = 0; col < cols; ++col) { NDView yv(&y(row, col, 0), {n_points}); NDView ev(&y_err(row, col, 0), {n_points}); const auto single = aare::fit_pixel(model, x.view(), yv, ev); // reused solver buffers must not leak state between pixels for (ssize_t k = 0; k < 3; ++k) { CHECK(par(row, col, k) == Approx(single(k)).margin(1e-12)); CHECK(err(row, col, k) == Approx(single(3 + k)).margin(1e-12)); CHECK(err(row, col, k) > 0.0); } CHECK(chi2(row, col) == Approx(single(6)).margin(1e-12)); const double tol = truth_tolerance(minimizer); CHECK(par(row, col, 0) == Approx(80.0 + 10.0 * static_cast(row)) .epsilon(tol) .margin(1e-6)); CHECK(par(row, col, 1) == Approx(-0.6 + 0.3 * static_cast(col)) .epsilon(tol) .margin(1e-6)); CHECK(par(row, col, 2) == Approx(0.9 + 0.1 * static_cast(row)) .epsilon(tol) .margin(1e-6)); } } } TEST_CASE("Fitting with every parameter fixed returns the fixed values", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); auto d = gaussian_data(); auto model = make_model(minimizer, true); model.FixParameter("A", 120.0); model.FixParameter("mu", 0.8); model.FixParameter("sigma", 1.3); const auto result = aare::fit_pixel(model, d.x.view(), d.y.view()); REQUIRE(result.size() == 7); CHECK(result(0) == 120.0); CHECK(result(1) == 0.8); CHECK(result(2) == 1.3); for (ssize_t k = 3; k < 6; ++k) CHECK(result(k) == 0.0); CHECK(result(6) == Approx(0.0).margin(1e-12)); } TEST_CASE("All minimizers agree on Gaussian fits", "[fit]") { using aare::model::Gaussian; const auto none = [](auto &) {}; check_minimizers_agree("exact, unweighted", gaussian_data(), false, false, 100, 1e-4, none); check_minimizers_agree("exact, weighted with errors", gaussian_data(), true, true, 100, 1e-4, none); check_minimizers_agree( "sigma fixed at a wrong value", gaussian_data(), true, true, 100, 1e-3, [](auto &m) { m.FixParameter("sigma", 1.5); }, 0.2); check_minimizers_agree( "sigma fixed at the true value", gaussian_data(), true, true, 100, 1e-3, [](auto &m) { m.FixParameter("sigma", 1.3); }); check_minimizers_agree( "active upper limit on mu", gaussian_data(), false, false, 100, 1e-3, [](auto &m) { m.SetParLimits("mu", -5.0, 0.5); }); check_minimizers_agree( "inactive limits", gaussian_data(), false, false, 100, 1e-4, [](auto &m) { m.SetParLimits("mu", -5.0, 5.0); }); check_minimizers_agree( "active upper limit on sigma", gaussian_data(), true, true, 100, 1e-3, [](auto &m) { m.SetParLimits("sigma", 0.5, 1.0); }, 0.2); check_minimizers_agree( "user start value", gaussian_data(), false, false, 100, 1e-4, [](auto &m) { m.SetParameter("mu", 0.0); }); check_minimizers_agree( "fixed amplitude and active limit on mu", gaussian_data(), true, true, 100, 1e-3, [](auto &m) { m.FixParameter("A", 100.0); m.SetParLimits("mu", 0.6, 0.7); }, 0.2); check_minimizers_agree("noisy, weighted with errors", gaussian_data(true), true, true, 500, 1e-3, none); check_minimizers_agree( "noisy, fixed mu and active limit on sigma", gaussian_data(true), true, true, 500, 1e-3, [](auto &m) { m.FixParameter("mu", 0.8); m.SetParLimits("sigma", 0.5, 1.1); }, 0.2); } TEST_CASE("All minimizers agree on S-curve fits", "[fit]") { using aare::model::RisingScurve; const std::vector truth{10.0, 0.1, 50.0, 3.0, 1000.0, 2.0}; const auto none = [](auto &) {}; check_minimizers_agree("noisy, weighted with errors", scurve_data(truth, true), true, true, 500, 2e-2, none); check_minimizers_agree("noisy, fixed slope and limited width", scurve_data(truth, true), true, true, 500, 1e-2, [](auto &m) { m.FixParameter("p1", 0.1); m.SetParLimits("sigma", 1.0, 4.0); }); check_minimizers_agree( "noisy, active limit on the threshold", scurve_data(truth, true), false, false, 500, 1e-2, [](auto &m) { m.SetParLimits("mu", 40.0, 48.0); }); check_minimizers_agree( "noisy, two fixed, limited amplitude, start value", scurve_data(truth, true), true, true, 500, 1e-2, [](auto &m) { m.FixParameter("sigma", 3.0); m.FixParameter("p0", 10.0); m.SetParLimits("A", 900.0, 1100.0); m.SetParameter("C", 0.0); }); } TEST_CASE("All minimizers agree on a noisy data cube", "[fit]") { constexpr ssize_t rows = 3; constexpr ssize_t cols = 2; NDArray x({n_points}); NDArray y({rows, cols, n_points}); NDArray y_err({rows, cols, n_points}, 1.0); for (ssize_t row = 0; row < rows; ++row) { for (ssize_t col = 0; col < cols; ++col) { const double mu = -0.6 + 0.3 * static_cast(col); const double sigma = 0.9 + 0.1 * static_cast(row); for (ssize_t i = 0; i < n_points; ++i) { x(i) = -6.0 + 0.2 * static_cast(i); const double z = (x(i) - mu) / sigma; y(row, col, i) = 80.0 * std::exp(-0.5 * z * z) + 2.0 * std::sin(7.3 * static_cast(i)); } } } struct Cube { NDArray par{{rows, cols, 3}}; NDArray err{{rows, cols, 3}}; NDArray chi2{{rows, cols}}; }; const auto fit_cube = [&](Minimizer minimizer) { Cube c; const auto model = make_model(minimizer, true, 500); aare::fit_3d(model, x.view(), y.view(), y_err.view(), c.par.view(), c.err.view(), c.chi2.view(), 2); return c; }; const auto ref = fit_cube(Minimizer::Migrad); for (const auto candidate : {Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro}) { INFO(name(candidate) << " vs Migrad"); const auto res = fit_cube(candidate); for (ssize_t row = 0; row < rows; ++row) { for (ssize_t col = 0; col < cols; ++col) { for (ssize_t k = 0; k < 3; ++k) { CHECK(res.par(row, col, k) == Approx(ref.par(row, col, k)).epsilon(1e-3)); CHECK(res.err(row, col, k) == Approx(ref.err(row, col, k)).epsilon(2e-2)); } CHECK(res.chi2(row, col) == Approx(ref.chi2(row, col)).epsilon(1e-4)); } } } } namespace { // The separable structure of a model must reproduce eval and eval_and_grad: // f = sum_j par[linear_par[j]] * phi[j], df/dpar[linear_par[j]] = phi[j] and // df/dpar[nonlinear[k]] = sum_j par[linear_par[j]] * dphi[k][j]. template void check_separable(const std::vector &par, const std::vector &xs) { using Traits = aare::model::separable_traits; static_assert(aare::model::is_separable::value); typename Traits::Basis phi{}; typename Traits::BasisGrad dphi{}; std::array g{}; double f = 0.0; for (const double x : xs) { INFO("x = " << x); Model::basis_and_grad(x, par, phi, dphi); Model::eval_and_grad(x, par, f, g); double sum = 0.0; for (std::size_t j = 0; j < Traits::nlin; ++j) { const auto idx = Model::linear_par[j]; sum += par[idx] * phi[j]; CHECK(g[idx] == Approx(phi[j]).epsilon(1e-12).margin(1e-14)); } CHECK(Model::eval(x, par) == Approx(sum).epsilon(1e-12).margin(1e-14)); CHECK(f == Approx(sum).epsilon(1e-12).margin(1e-14)); for (std::size_t k = 0; k < Traits::nnl; ++k) { double d = 0.0; for (std::size_t j = 0; j < Traits::nlin; ++j) d += par[Model::linear_par[j]] * dphi[k][j]; CHECK(g[Traits::nonlinear_par[k]] == Approx(d).epsilon(1e-12).margin(1e-14)); } } // The bulk basis pass must agree with the per-point one. if constexpr (aare::model::has_basis_columns::value) { const auto n = static_cast(xs.size()); std::vector cols(Traits::nlin * xs.size()); std::vector dcols(Traits::nnl * Traits::nlin * xs.size()); Model::basis_columns(xs.data(), n, par, cols.data(), dcols.data()); for (ssize_t i = 0; i < n; ++i) { INFO("x = " << xs[static_cast(i)]); Model::basis_and_grad(xs[static_cast(i)], par, phi, dphi); for (std::size_t j = 0; j < Traits::nlin; ++j) CHECK(cols[j * xs.size() + static_cast(i)] == Approx(phi[j]).epsilon(1e-12).margin(1e-14)); for (std::size_t k = 0; k < Traits::nnl; ++k) for (std::size_t j = 0; j < Traits::nlin; ++j) CHECK(dcols[(k * Traits::nlin + j) * xs.size() + static_cast(i)] == Approx(dphi[k][j]).epsilon(1e-12).margin(1e-14)); } } } } // namespace TEST_CASE("Separable models are consistent with eval and eval_and_grad", "[fit]") { using namespace aare::model; const std::vector xs = {-4.0, -1.5, 0.3, 2.0, 7.5, 40.0, 55.0, 120.0}; check_separable({2.0, -0.5}, xs); check_separable({2.0, -0.5, 0.05}, xs); check_separable({120.0, 0.8, 1.3}, xs); check_separable({80.0, 15.0, 2.0, 1.5}, xs); check_separable({20.0, 0.05, 50.0, 4.0, 1500.0, 0.3}, xs); check_separable( {20.0, 0.05, 50.0, 4.0, 1500.0, 0.3, 1.1, 0.1}, xs); check_separable({10.0, 0.1, 50.0, 3.0, 1000.0, 2.0}, xs); check_separable({10.0, 0.1, 50.0, 3.0, 1000.0, 2.0}, xs); } TEST_CASE("VarPro falls back to LevenbergMarquardt for a limit " "on a linear parameter", "[fit]") { using aare::model::FallingScurve; const std::vector truth{10.0, 0.1, 50.0, 3.0, 1000.0, 2.0}; auto d = scurve_data(truth); auto vp = make_model(Minimizer::VarPro, true, 500); auto lm = make_model(Minimizer::LevenbergMarquardt, true, 500); // The unconstrained amplitude is 1000, so the linear solve cannot // honour this limit and the pixel is refitted with the full solver. vp.SetParLimits("A", 0.0, 500.0); lm.SetParLimits("A", 0.0, 500.0); const auto res_vp = aare::fit_pixel(vp, d.x.view(), d.y.view(), d.y_err.view()); const auto res_lm = aare::fit_pixel(lm, d.x.view(), d.y.view(), d.y_err.view()); REQUIRE(res_vp.size() == res_lm.size()); for (ssize_t k = 0; k < res_vp.size(); ++k) CHECK(res_vp(k) == res_lm(k)); CHECK(res_vp(4) == Approx(500.0).margin(1e-9)); CHECK(res_vp(12) > 0.0); } TEST_CASE("fast_exp matches std::exp", "[fit]") { double max_rel = 0.0; for (int i = 0; i <= 14000; ++i) { const double x = -700.0 + 1400.0 * static_cast(i) / 14000.0; const double reference = std::exp(x); max_rel = std::max(max_rel, std::abs(aare::model::fast_exp(x) - reference) / reference); } CHECK(max_rel < 1e-15); CHECK(aare::model::fast_exp(0.0) == 1.0); CHECK(aare::model::fast_exp(-1000.0) == aare::model::fast_exp(-700.0)); } TEST_CASE("Fit with a one-sided limit far from the start value", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); constexpr ssize_t n = 20; NDArray x({n}); NDArray y({n}); for (ssize_t i = 0; i < n; ++i) { x(i) = static_cast(i); y(i) = 2.0 + 0.5 * x(i); } const double inf = std::numeric_limits::infinity(); const bool upper = GENERATE(true, false); auto model = make_model(minimizer); if (upper) model.SetParLimits(0, -inf, 1e9); else model.SetParLimits(0, -1e9, inf); const auto res = aare::fit_pixel(model, x.view(), y.view()); CHECK(res(0) == Approx(2.0).epsilon(1e-6)); CHECK(res(1) == Approx(0.5).epsilon(1e-6)); } TEST_CASE("A parameter ending exactly on a limit reports an error of 0", "[fit]") { const auto minimizer = GENERATE(Minimizer::Migrad, Minimizer::Fumili, Minimizer::LevenbergMarquardt, Minimizer::VarPro); INFO("minimizer " << name(minimizer)); constexpr ssize_t n = 3; NDArray x({n}); NDArray y({n}, 2.0); for (ssize_t i = 0; i < n; ++i) x(i) = static_cast(i); // The unconstrained minimum p0 = 2 coincides with the limit. const bool lower = GENERATE(true, false); auto model = make_model(minimizer, true); model.FixParameter(1, 0.0); if (lower) model.SetParLimits(0, 2.0, 3.0); else model.SetParLimits(0, 1.0, 2.0); const auto res = aare::fit_pixel(model, x.view(), y.view()); REQUIRE(res.size() == 5); CHECK(res(0) == Approx(2.0).epsilon(1e-6)); CHECK(res(2) == 0.0); CHECK(res(3) == 0.0); }