initial implementation

This commit is contained in:
Erik Fröjdh committed 2026-09-24 17:20:44 +02:00
1 parent 4677e1003e
commit b74c0afb97
11 files changed
+634 -154

No files matched your search

+7
View File
@@ -29,6 +29,13 @@
- Added string representator in python for Cluster and Eta
- Added roi slice method in python for easy slicing of numpy arrays ``array[roi.slice()]``.
- added context manager for ``aare.RawMasterFile``
- Added a configurable Minuit2 minimizer for the fit models. ``Minimizer.Migrad``
remains the default; ``Minimizer.Fumili`` uses the analytic model derivatives
for a Gauss-Newton style minimisation that needs far fewer function
evaluations. Select it with the ``minimizer`` constructor argument or property
in Python, or ``FitModel::SetMinimizer`` in C++. With ``compute_errors``,
Fumili reports parameter errors from its linearised covariance instead of
running Hesse.
### API Changes:
+83 -36
View File
@@ -75,52 +75,31 @@ static void report_accuracy(benchmark::State &state, const TestCase &tc,
// Benchmarks
// ----------
// Minuit2, analytic gradient (no Hesse)
static void BM_FitGausMinuitGrad(benchmark::State &state) {
const auto &tc = get_test_cases()[state.range(0)];
auto data = generate_gaussian_data(tc);
auto xv = data.x.view();
auto yv = data.y.view();
const auto model = aare::FitModel<aare::model::Gaussian>(
/*strategy = */ 0,
/*max_calls = */ 500, // increase for noisy signals
/*tolerance = */ 0.5,
/*compute_errors = */ false);
aare::NDArray<double, 1> result;
for (auto _ : state) {
result = aare::fit_pixel<aare::model::Gaussian>(model, xv, yv);
benchmark::DoNotOptimize(result.data());
}
report_accuracy(state, tc, result);
state.SetLabel(tc.name);
}
// Minuit2, analytic gradient + Hesse
static void BM_FitGausMinuitGradHesse(benchmark::State &state) {
static void run_gaussian_fit(benchmark::State &state, aare::Minimizer minimizer,
bool compute_errors) {
const auto &tc = get_test_cases()[state.range(0)];
auto data = generate_gaussian_data(tc);
auto xv = data.x.view();
auto yv = data.y.view();
auto ev = data.y_err.view();
const auto model = aare::FitModel<aare::model::Gaussian>(
0, 500, 0.5, true); // compute_errors = true -> Runs Hesse and provides
// errors on fitted params
const aare::FitModel<aare::model::Gaussian> model(
/*strategy = */ 0,
/*max_calls = */ 500, // increase for noisy signals
/*tolerance = */ 0.5, compute_errors, minimizer);
aare::NDArray<double, 1> result;
for (auto _ : state) {
result = aare::fit_pixel<aare::model::Gaussian>(model, xv, yv, ev);
result = compute_errors
? aare::fit_pixel<aare::model::Gaussian>(model, xv, yv, ev)
: aare::fit_pixel<aare::model::Gaussian>(model, xv, yv);
benchmark::DoNotOptimize(result.data());
}
// result has 6 elements: [A, mu, sig, err_A, err_mu, err_sig]
report_accuracy(state, tc, result);
// Also report Hesse uncertainties
if (result.size() >= 6) {
// With errors the result is [A, mu, sig, err_A, err_mu, err_sig, chi2]
if (compute_errors && result.size() >= 6) {
state.counters["errA"] = result(3);
state.counters["errMu"] = result(4);
state.counters["errSig"] = result(5);
@@ -128,12 +107,80 @@ static void BM_FitGausMinuitGradHesse(benchmark::State &state) {
state.SetLabel(tc.name);
}
BENCHMARK(BM_FitGausMinuitGrad)
// Migrad, analytic gradient (no Hesse)
static void BM_FitGausMigrad(benchmark::State &state) {
run_gaussian_fit(state, aare::Minimizer::Migrad, false);
}
// Migrad, analytic gradient + Hesse for the parameter errors
static void BM_FitGausMigradHesse(benchmark::State &state) {
run_gaussian_fit(state, aare::Minimizer::Migrad, true);
}
// Fumili, analytic gradient and linearised Hessian
static void BM_FitGausFumili(benchmark::State &state) {
run_gaussian_fit(state, aare::Minimizer::Fumili, false);
}
// Fumili, parameter errors from the linearised covariance
static void BM_FitGausFumiliErrors(benchmark::State &state) {
run_gaussian_fit(state, aare::Minimizer::Fumili, true);
}
// ----------------------------------------------------------------
// Rising S-curve (6 parameters), typical for threshold scans
// ----------------------------------------------------------------
static constexpr ssize_t N_SCURVE_POINTS = 100;
static void run_scurve_fit(benchmark::State &state, aare::Minimizer minimizer) {
const std::vector<double> truth = {5.0, 0.1, 50.0, 4.0, 200.0, 0.5};
const double noise_sigma = 0.02 * truth[4];
aare::NDArray<double, 1> x({N_SCURVE_POINTS});
aare::NDArray<double, 1> y({N_SCURVE_POINTS});
std::mt19937 rng(SEED);
std::normal_distribution<double> noise(0.0, noise_sigma);
for (ssize_t i = 0; i < N_SCURVE_POINTS; ++i) {
x[i] = static_cast<double>(i);
y[i] = aare::model::RisingScurve::eval(x[i], truth) + noise(rng);
}
auto xv = x.view();
auto yv = y.view();
const aare::FitModel<aare::model::RisingScurve> model(0, 500, 0.5, false,
minimizer);
aare::NDArray<double, 1> result;
for (auto _ : state) {
result = aare::fit_pixel<aare::model::RisingScurve>(model, xv, yv);
benchmark::DoNotOptimize(result.data());
}
state.counters["dMu"] = result(2) - truth[2];
state.counters["dSig"] = result(3) - truth[3];
state.counters["dA"] = result(4) - truth[4];
state.counters["chi2"] = result(6);
}
static void BM_FitScurveMigrad(benchmark::State &state) {
run_scurve_fit(state, aare::Minimizer::Migrad);
}
static void BM_FitScurveFumili(benchmark::State &state) {
run_scurve_fit(state, aare::Minimizer::Fumili);
}
BENCHMARK(BM_FitGausMigrad)->DenseRange(0, 5)->Unit(benchmark::kMicrosecond);
BENCHMARK(BM_FitGausFumili)->DenseRange(0, 5)->Unit(benchmark::kMicrosecond);
BENCHMARK(BM_FitGausMigradHesse)
->DenseRange(0, 5)
->Unit(benchmark::kMicrosecond);
BENCHMARK(BM_FitGausFumiliErrors)
->DenseRange(0, 5)
->Unit(benchmark::kMicrosecond);
BENCHMARK(BM_FitGausMinuitGradHesse)
->DenseRange(0, 5)
->Unit(benchmark::kMicrosecond);
BENCHMARK(BM_FitScurveMigrad)->Unit(benchmark::kMicrosecond);
BENCHMARK(BM_FitScurveFumili)->Unit(benchmark::kMicrosecond);
BENCHMARK_MAIN();
+23
View File
@@ -27,4 +27,27 @@ For three-dimensional data, pass an array with shape
The result dictionary contains ``par`` and ``chi2``. It also contains
``par_err`` when ``compute_errors`` is enabled.
Choosing the minimizer
~~~~~~~~~~~~~~~~~~~~~~
Fits use Minuit2's Migrad by default. Fumili is a Gauss-Newton style
minimizer that takes the gradient and a linearised Hessian from the analytic
derivatives of the model, so it usually converges in far fewer function
evaluations. Select it with the ``minimizer`` argument or property::
model = Gaussian(minimizer=Minimizer.Fumili)
model.minimizer = Minimizer.Migrad
In the bundled ``fit_benchmark`` Fumili is about 1.5 times faster than Migrad
for Gaussian peaks with little noise and about three times faster for the
six-parameter S-curves. For very noisy data the Gauss-Newton steps converge
more slowly, so compare both minimizers on representative data.
With ``compute_errors`` enabled, Migrad reports parameter errors from Hesse
while Fumili reports them from the covariance of its linearised Hessian, so
Fumili does not run an extra Hesse step.
.. autofunction:: fit
.. autoclass:: Minimizer
:members: Migrad, Fumili
+4
View File
@@ -14,6 +14,10 @@ namespace aare {
/**
* @brief Fit a single pixel's data using Minuit2.
*
* The minimizer (Migrad or Fumili) is selected by the model, see
* aare::Minimizer. Parameter errors, when requested, come from MnHesse with
* Migrad and from the linearised covariance with Fumili.
*
* User-precedence rules:
* - Fixed parameters: untouched (value and fixed flag preserved from model).
* - User-set start: value preserved, step size auto-filled.
+21 -2
View File
@@ -10,6 +10,19 @@
namespace aare {
/**
* @brief Minuit2 minimizer used by FitModel.
*
* - Migrad: variable-metric minimizer driven by the analytic gradient of the
* chi-squared. Parameter errors, when requested, come from MnHesse.
* - Fumili: Gauss-Newton style minimizer for least-squares problems. It takes
* the gradient and a linearised Hessian from the model's analytic
* derivatives, so it typically needs far fewer function evaluations than
* Migrad. Parameter errors, when requested, come from the covariance of that
* linearised Hessian without an extra Hesse step.
*/
enum class Minimizer { Migrad, Fumili };
template <typename Model> class FitModel {
// Forward declaration only — full definition lives in src/FitModelImpl.hpp
@@ -20,6 +33,7 @@ template <typename Model> class FitModel {
unsigned int max_calls_;
double tolerance_;
bool compute_errors_;
Minimizer minimizer_;
std::array<bool, Model::npar> user_fixed_{};
std::array<bool, Model::npar> user_start_{};
@@ -37,10 +51,13 @@ template <typename Model> class FitModel {
* default).
* @param max_calls Maximum FCN calls per pixel minimisation.
* @param tolerance Minuit2 EDM tolerance.
* @param compute_errors If true, run MnHesse after minimisation.
* @param compute_errors If true, also report parameter errors: from
* MnHesse with Migrad, from the Fumili covariance with Fumili.
* @param minimizer Minuit2 minimizer, see aare::Minimizer.
*/
explicit FitModel(unsigned int strategy = 0, unsigned int max_calls = 100,
double tolerance = 0.5, bool compute_errors = false);
double tolerance = 0.5, bool compute_errors = false,
Minimizer minimizer = Minimizer::Migrad);
// Destructor must be defined in Fit.cpp where FitModelImpl is complete.
~FitModel();
@@ -76,10 +93,12 @@ template <typename Model> class FitModel {
void SetMaxCalls(unsigned int n) { max_calls_ = n; }
void SetTolerance(double t) { tolerance_ = t; }
void SetComputeErrors(bool b) { compute_errors_ = b; }
void SetMinimizer(Minimizer m) { minimizer_ = m; }
unsigned int max_calls() const { return max_calls_; }
double tolerance() const { return tolerance_; }
bool compute_errors() const { return compute_errors_; }
Minimizer minimizer() const { return minimizer_; }
bool is_user_fixed(unsigned int idx) const { return user_fixed_[idx]; }
bool is_user_start(unsigned int idx) const { return user_start_[idx]; }
+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
+26 -2
View File
@@ -22,9 +22,11 @@ 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)
.def("SetParLimits",
py::overload_cast<unsigned int, double, double>(&FM::SetParLimits),
py::arg("idx"), py::arg("lo"), py::arg("hi"))
@@ -59,6 +61,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__",
[](const FM & /*self*/,
@@ -88,6 +91,9 @@ 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, or ``Minimizer.Fumili``).
Parameters
----------
x : array_like, shape (n_scan,)
@@ -227,6 +233,22 @@ fit_dispatch(const aare::FitModel<Model> &model,
}
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(
Minuit2 minimizer used by the fit models.
``Migrad`` is the default variable-metric minimizer. With
``compute_errors`` its parameter errors come from Hesse.
``Fumili`` is a Gauss-Newton style minimizer that takes the gradient
and a linearised Hessian from the analytic derivatives of the model.
It usually needs far fewer function evaluations than Migrad. With
``compute_errors`` its parameter errors come from the covariance of
the linearised Hessian, without an extra Hesse step.
)doc")
.value("Migrad", aare::Minimizer::Migrad)
.value("Fumili", aare::Minimizer::Fumili);
// ── Bind model classes ──────────────────────────────────────────
bind_fit_model<aare::model::Gaussian>(m, "Gaussian");
bind_fit_model<aare::model::GaussianErfcPlateau>(m, "GaussianErfcPlateau");
@@ -325,6 +347,8 @@ void define_fit_bindings(py::module &m) {
model : Pol1, Pol2, Gaussian, RisingScurve, or FallingScurve
Configured model object. User-set limits, fixed parameters,
and start values take precedence over automatic estimates.
The model's ``minimizer`` property selects Migrad (default)
or Fumili.
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)
+38
View File
@@ -13,3 +13,41 @@ def test_gaussian_model_evaluates_and_fits_data():
result = model.fit(x, y)
np.testing.assert_allclose(result["par"], expected, atol=2e-3)
def test_minimizer_can_be_selected_and_fumili_fits_data():
x = np.linspace(-5.0, 5.0, 51)
expected = np.array([20.0, 0.5, 1.2])
assert aare.Gaussian().minimizer == aare.Minimizer.Migrad
model = aare.Gaussian(minimizer=aare.Minimizer.Fumili)
assert model.minimizer == aare.Minimizer.Fumili
y = model(x, expected)
result = model.fit(x, y)
np.testing.assert_allclose(result["par"], expected, atol=2e-3)
model.minimizer = aare.Minimizer.Migrad
assert model.minimizer == aare.Minimizer.Migrad
def test_fumili_and_migrad_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)
migrad = aare.Gaussian(compute_errors=True)
fumili = aare.Gaussian(compute_errors=True, minimizer=aare.Minimizer.Fumili)
with_migrad = migrad.fit(x, y, y_err, n_threads=2)
with_fumili = fumili.fit(x, y, y_err, n_threads=2)
assert with_fumili["par"].shape == (2, 3, 3)
assert with_fumili["par_err"].shape == (2, 3, 3)
for result in (with_migrad, with_fumili):
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(with_fumili["par_err"], with_migrad["par_err"], rtol=2e-2)
+203 -90
View File
@@ -3,8 +3,9 @@
#include "aare/Models.hpp"
#include "aare/NDView.hpp"
#include <Minuit2/FCNGradientBase.h>
#include <algorithm>
#include <cmath>
#include <Minuit2/FumiliFCNBase.h>
#include <array>
#include <cstddef>
#include <stdexcept>
#include <vector>
@@ -13,7 +14,14 @@ namespace aare {
namespace func {
/**
* @brief Generic chi-squared FCN with analytic gradient for a 1D model.
* @brief Chi-squared objective of a 1D model and its analytic derivatives.
*
* Shared by the Minuit2 FCN adapters below.
*
* chi2(par) = sum_i w_i * (y_i - f(x_i; par))^2
*
* with w_i = 1/sigma_i^2 for weighted fits (points with sigma_i == 0 are
* skipped) and w_i = 1 otherwise.
*
* @tparam Model A model struct that satisfies:
* - static constexpr std::size_t npar;
@@ -22,106 +30,182 @@ namespace func {
* double& f, std::array<double, npar>& g);
* - static bool is_valid(const std::vector<double>& par);
*
* Gradient:
* d(chi2)/dp_k = -2 * sum_i w_i * (y_i - f_i) * df_i/dp_k
* Derivatives, with df_i/dp_k taken from Model::eval_and_grad:
*
* where w_i = 1/sigma_i^2 (weighted) or 1 (unweighted).
* d(chi2)/dp_k = -2 * sum_i w_i * (y_i - f_i) * df_i/dp_k
* d2(chi2)/dp_j dp_k ~= 2 * sum_i w_i * df_i/dp_j * df_i/dp_k
*
* By providing analytic gradients we avoid 2*npar extra function evaluations
* per Minuit step that would otherwise be spent on finite differences.
* The second line drops the residual-weighted second derivatives of the
* model, which is the Fumili (Gauss-Newton) approximation of the Hessian.
*
* Invalid model parameters do not throw; they return a large penalty (with
* a zero gradient and unit curvature) so the minimizer can remain in control.
*
* @throws std::invalid_argument if par.size() != Model::npar.
*/
template <class Model> class Chi2Data1D {
public:
static constexpr std::size_t npar = Model::npar;
/** @brief Number of elements of the packed symmetric Hessian. */
static constexpr std::size_t nhess = npar * (npar + 1) / 2;
static constexpr double invalid_penalty = 1e20;
/** @brief An empty y_err view selects an unweighted fit. */
Chi2Data1D(NDView<double, 1> x, NDView<double, 1> y,
NDView<double, 1> y_err = NDView<double, 1>{})
: x_(x), y_(y), s_(y_err), weighted_(y_err.size() > 0) {}
double value(const std::vector<double> &par) const {
check_size(par);
if (!Model::is_valid(par))
return invalid_penalty;
return weighted_ ? value_impl<true>(par) : value_impl<false>(par);
}
std::vector<double> gradient(const std::vector<double> &par) const {
check_size(par);
std::vector<double> grad(npar, 0.0);
if (!Model::is_valid(par))
return grad;
if (weighted_)
gradient_impl<true>(par, grad);
else
gradient_impl<false>(par, grad);
return grad;
}
/**
* @brief Compute chi2, its gradient and the Gauss-Newton Hessian in a
* single pass over the data.
*
* @param grad Receives d(chi2)/dp_k, resized to npar.
* @param hess Receives the packed symmetric Hessian in the layout used by
* ROOT::Minuit2::FumiliFCNBase: element (j, k) with j <= k is
* stored at index j + k * (k + 1) / 2. Resized to nhess.
* @return chi2 at par.
*/
double evaluate_all(const std::vector<double> &par,
std::vector<double> &grad,
std::vector<double> &hess) const {
check_size(par);
grad.assign(npar, 0.0);
hess.assign(nhess, 0.0);
if (!Model::is_valid(par)) {
for (std::size_t k = 0; k < npar; ++k)
hess[k + k * (k + 1) / 2] = 1.0;
return invalid_penalty;
}
return weighted_ ? evaluate_all_impl<true>(par, grad, hess)
: evaluate_all_impl<false>(par, grad, hess);
}
private:
static void check_size(const std::vector<double> &par) {
if (par.size() != npar) {
throw std::invalid_argument(
"Chi2Data1D: wrong parameter vector size.");
}
}
template <bool Weighted>
double value_impl(const std::vector<double> &par) const {
double chi2 = 0.0;
for (ssize_t i = 0; i < x_.size(); ++i) {
if constexpr (Weighted) {
const double si = s_[i];
if (si == 0.0)
continue;
const double r_i = y_[i] - Model::eval(x_[i], par);
chi2 += (r_i * r_i) / (si * si);
} else {
const double r_i = y_[i] - Model::eval(x_[i], par);
chi2 += r_i * r_i;
}
}
return chi2;
}
template <bool Weighted>
void gradient_impl(const std::vector<double> &par,
std::vector<double> &grad) const {
std::array<double, npar> df{};
double f_i = 0.0;
for (ssize_t i = 0; i < x_.size(); ++i) {
double c = 0.0;
if constexpr (Weighted) {
const double si = s_[i];
if (si == 0.0)
continue;
Model::eval_and_grad(x_[i], par, f_i, df);
c = -2.0 * (y_[i] - f_i) / (si * si);
} else {
Model::eval_and_grad(x_[i], par, f_i, df);
c = -2.0 * (y_[i] - f_i);
}
for (std::size_t k = 0; k < npar; ++k)
grad[k] += c * df[k];
}
}
template <bool Weighted>
double evaluate_all_impl(const std::vector<double> &par,
std::vector<double> &grad,
std::vector<double> &hess) const {
std::array<double, npar> df{};
double f_i = 0.0;
double chi2 = 0.0;
for (ssize_t i = 0; i < x_.size(); ++i) {
double w = 1.0;
if constexpr (Weighted) {
const double si = s_[i];
if (si == 0.0)
continue;
w = 1.0 / (si * si);
}
Model::eval_and_grad(x_[i], par, f_i, df);
const double r_i = y_[i] - f_i;
chi2 += w * r_i * r_i;
const double c = -2.0 * w * r_i;
for (std::size_t k = 0; k < npar; ++k) {
grad[k] += c * df[k];
const double h_k = 2.0 * w * df[k];
double *column = hess.data() + k * (k + 1) / 2;
for (std::size_t j = 0; j <= k; ++j)
column[j] += h_k * df[j];
}
}
return chi2;
}
NDView<double, 1> x_;
NDView<double, 1> y_;
NDView<double, 1> s_;
bool weighted_;
};
/**
* @brief Chi-squared FCN with analytic gradient, used by Migrad.
*
* Invalid model parameters do not throw; they return a large penalty
* (and a zero gradient fallback) so the minimizer can remain in control.
* Providing the analytic gradient avoids the 2*npar extra function
* evaluations per Minuit step that finite differences would need.
*/
template <class Model>
class Chi2Model1DGrad : public ROOT::Minuit2::FCNGradientBase {
public:
Chi2Model1DGrad(NDView<double, 1> x, NDView<double, 1> y)
: x_(x), y_(y), s_(), weighted_(false) {}
/** @brief An empty y_err view selects an unweighted fit. */
Chi2Model1DGrad(NDView<double, 1> x, NDView<double, 1> y,
NDView<double, 1> y_err)
: x_(x), y_(y), s_(y_err), weighted_(true) {}
NDView<double, 1> y_err = NDView<double, 1>{})
: data_(x, y, y_err) {}
~Chi2Model1DGrad() override = default;
double operator()(const std::vector<double> &par) const override {
if (par.size() != Model::npar) {
throw std::invalid_argument(
"Chi2Model1DGrad: wrong parameter vector size.");
}
if (!Model::is_valid(par))
return 1e20;
double chi2 = 0.0;
if (weighted_) {
for (ssize_t i = 0; i < x_.size(); ++i) {
const double si = s_[i];
if (si == 0.0)
continue;
const double f_i = Model::eval(x_[i], par);
const double r_i = y_[i] - f_i;
chi2 += (r_i * r_i) / (si * si);
}
} else {
for (ssize_t i = 0; i < x_.size(); ++i) {
const double f_i = Model::eval(x_[i], par);
const double r_i = y_[i] - f_i;
chi2 += r_i * r_i;
}
}
return chi2;
return data_.value(par);
}
std::vector<double>
Gradient(const std::vector<double> &par) const override {
if (par.size() != Model::npar) {
throw std::invalid_argument(
"Chi2Model1DGrad: wrong parameter vector size.");
}
std::vector<double> grad(Model::npar, 0.0);
if (!Model::is_valid(par))
return grad;
std::array<double, Model::npar> df{};
double f_i = 0.0;
if (weighted_) {
for (ssize_t i = 0; i < x_.size(); ++i) {
const double si = s_[i];
if (si == 0.0)
continue;
Model::eval_and_grad(x_[i], par, f_i, df);
const double r_i = y_[i] - f_i;
const double c = -2.0 * r_i / (si * si);
for (std::size_t k = 0; k < Model::npar; ++k) {
grad[k] += c * df[k];
}
}
} else {
for (ssize_t i = 0; i < x_.size(); ++i) {
Model::eval_and_grad(x_[i], par, f_i, df);
const double r_i = y_[i] - f_i;
const double c = -2.0 * r_i;
for (std::size_t k = 0; k < Model::npar; ++k) {
grad[k] += c * df[k];
}
}
}
return grad;
return data_.gradient(par);
}
/** @brief Error definition: 1.0 for chi-squared (delta_chi2 = 1 ->
@@ -129,12 +213,41 @@ class Chi2Model1DGrad : public ROOT::Minuit2::FCNGradientBase {
double Up() const override { return 1.0; }
private:
NDView<double, 1> x_;
NDView<double, 1> y_;
NDView<double, 1> s_;
bool weighted_;
Chi2Data1D<Model> data_;
};
/**
* @brief Chi-squared FCN for Fumili.
*
* EvaluateAll fills the value, gradient and Gauss-Newton Hessian cached by
* FumiliFCNBase in one pass over the data, so Fumili never needs numerical
* derivatives.
*/
template <class Model>
class Chi2Model1DFumili : public ROOT::Minuit2::FumiliFCNBase {
public:
/** @brief An empty y_err view selects an unweighted fit. */
Chi2Model1DFumili(NDView<double, 1> x, NDView<double, 1> y,
NDView<double, 1> y_err = NDView<double, 1>{})
: ROOT::Minuit2::FumiliFCNBase(static_cast<unsigned int>(Model::npar)),
data_(x, y, y_err) {}
double operator()(const std::vector<double> &par) const override {
return data_.value(par);
}
/** @brief Error definition: 1.0 for chi-squared (delta_chi2 = 1 ->
* 1-sigma). */
double Up() const override { return 1.0; }
void EvaluateAll(const std::vector<double> &par) override {
SetFCNValue(data_.evaluate_all(par, Gradient(), Hessian()));
}
private:
Chi2Data1D<Model> data_;
};
} // namespace func
} // namespace aare
} // namespace aare
+58 -24
View File
@@ -2,9 +2,11 @@
#include "aare/Fit.hpp"
#include "Chi2.hpp"
#include "Minuit2/FunctionMinimum.h"
#include "Minuit2/MnFumiliMinimize.h"
#include "Minuit2/MnHesse.h"
#include "Minuit2/MnMigrad.h"
#include "Minuit2/MnStrategy.h"
#include "Minuit2/MnUserParameterState.h"
#include "Minuit2/MnUserParameters.h"
#include "aare/Models.hpp"
#include "aare/utils/par.hpp"
@@ -14,6 +16,7 @@
#include <memory>
#include <stdexcept>
#include <type_traits>
#include <vector>
namespace aare {
// ============================================================================
@@ -34,9 +37,11 @@ template <typename Model> struct FitModel<Model>::FitModelImpl {
template <typename Model>
FitModel<Model>::FitModel(unsigned int strategy, unsigned int max_calls,
double tolerance, bool compute_errors)
double tolerance, bool compute_errors,
Minimizer minimizer)
: impl_(std::make_unique<FitModelImpl>(strategy)), max_calls_(max_calls),
tolerance_(tolerance), compute_errors_(compute_errors) {
tolerance_(tolerance), compute_errors_(compute_errors),
minimizer_(minimizer) {
for (std::size_t i = 0; i < npar; ++i) {
const auto pi = Model::param_info[i];
const bool has_lo = std::isfinite(pi.default_lo);
@@ -57,8 +62,8 @@ template <typename Model>
FitModel<Model>::FitModel(const FitModel &other)
: impl_(std::make_unique<FitModelImpl>(*other.impl_)),
max_calls_(other.max_calls_), tolerance_(other.tolerance_),
compute_errors_(other.compute_errors_), user_fixed_(other.user_fixed_),
user_start_(other.user_start_) {}
compute_errors_(other.compute_errors_), minimizer_(other.minimizer_),
user_fixed_(other.user_fixed_), user_start_(other.user_start_) {}
template <typename Model>
FitModel<Model> &FitModel<Model>::operator=(const FitModel &other) {
@@ -67,6 +72,7 @@ FitModel<Model> &FitModel<Model>::operator=(const FitModel &other) {
max_calls_ = other.max_calls_;
tolerance_ = other.tolerance_;
compute_errors_ = other.compute_errors_;
minimizer_ = other.minimizer_;
user_fixed_ = other.user_fixed_;
user_start_ = other.user_start_;
}
@@ -144,10 +150,38 @@ std::vector<std::string> FitModel<Model>::GetParNames() const {
// fit_pixel / fit_3d — Minuit2 template implementations
// ============================================================================
namespace {
/**
* @brief Copy the fitted values, optionally the errors, and the objective
* value of a minimum into the flat result layout documented in Fit.hpp.
*/
template <std::size_t npar>
NDArray<double, 1> pack_minimum(const ROOT::Minuit2::FunctionMinimum &min,
bool want_errors) {
const ssize_t result_size = want_errors ? (2 * npar + 1) : (npar + 1);
NDArray<double, 1> result({result_size});
const auto &state = min.UserState();
const std::vector<double> values = state.Params();
for (std::size_t k = 0; k < npar; ++k)
result[k] = values[k];
if (want_errors) {
const std::vector<double> errors = state.Errors();
for (std::size_t k = 0; k < npar; ++k)
result[npar + k] = errors[k];
}
result[result_size - 1] = min.Fval();
return result;
}
} // namespace
template <typename Model>
NDArray<double, 1> fit_pixel(const FitModel<Model> &model, NDView<double, 1> x,
NDView<double, 1> y, NDView<double, 1> y_err) {
using FCN = func::Chi2Model1DGrad<Model>;
constexpr std::size_t npar = Model::npar;
const bool want_errors = model.compute_errors();
const ssize_t result_size = want_errors ? (2 * npar + 1) : (npar + 1);
@@ -175,8 +209,23 @@ NDArray<double, 1> fit_pixel(const FitModel<Model> &model, NDView<double, 1> x,
upar_local.SetError(i, steps[i]);
}
auto chi2 = (y_err.size() > 0) ? FCN(x, y, y_err) : FCN(x, y);
if (model.minimizer() == Minimizer::Fumili) {
// Fumili takes the gradient and the Gauss-Newton Hessian from the FCN,
// so the covariance of the minimum is available without MnHesse.
func::Chi2Model1DFumili<Model> chi2(x, y, y_err);
ROOT::Minuit2::MnFumiliMinimize fumili(
chi2, ROOT::Minuit2::MnUserParameterState(upar_local),
model.impl()->strategy);
const ROOT::Minuit2::FunctionMinimum min =
fumili(model.max_calls(), model.tolerance());
if (!min.IsValid())
return NDArray<double, 1>({result_size}, 0.0);
return pack_minimum<npar>(min, want_errors);
}
func::Chi2Model1DGrad<Model> chi2(x, y, y_err);
ROOT::Minuit2::MnMigrad migrad(chi2, upar_local, model.impl()->strategy);
ROOT::Minuit2::FunctionMinimum min =
migrad(model.max_calls(), model.tolerance());
@@ -184,28 +233,13 @@ NDArray<double, 1> fit_pixel(const FitModel<Model> &model, NDView<double, 1> x,
if (!min.IsValid())
return NDArray<double, 1>({result_size}, 0.0);
if (want_errors) {
// MnHesse cannot handle a fit in which every parameter is fixed.
if (want_errors && upar_local.VariableParameters() > 0) {
ROOT::Minuit2::MnHesse hesse;
hesse(chi2, min);
const auto &values = min.UserState().Params();
const auto &errors = min.UserState().Errors();
NDArray<double, 1> result({result_size});
for (std::size_t k = 0; k < npar; ++k) {
result[k] = values[k];
result[npar + k] = errors[k];
}
result[2 * npar] = min.Fval();
return result;
}
const auto &values = min.UserState().Params();
NDArray<double, 1> result({result_size});
for (std::size_t k = 0; k < npar; ++k)
result[k] = values[k];
result[npar] = min.Fval();
return result;
return pack_minimum<npar>(min, want_errors);
}
template <typename Model>
+170
View File
@@ -6,6 +6,7 @@
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <cmath>
#include <vector>
namespace {
@@ -79,3 +80,172 @@ TEST_CASE("Minuit2 fits a Gaussian data cube in parallel", "[fit]") {
}
}
}
TEST_CASE("Fumili fits weighted and unweighted Gaussian data", "[fit]") {
aare::NDArray<double, 1> x({n_points});
aare::NDArray<double, 1> y({n_points});
aare::NDArray<double, 1> y_err({n_points}, 1.0);
fill_gaussian(x, y, 120.0, 0.8, 1.3);
aare::FitModel<aare::model::Gaussian> unweighted_model;
REQUIRE(unweighted_model.minimizer() == aare::Minimizer::Migrad);
unweighted_model.SetMinimizer(aare::Minimizer::Fumili);
REQUIRE(unweighted_model.minimizer() == aare::Minimizer::Fumili);
const auto unweighted =
aare::fit_pixel(unweighted_model, x.view(), y.view());
REQUIRE(unweighted.size() == 4);
CHECK(unweighted(0) == Catch::Approx(120.0).epsilon(1e-5));
CHECK(unweighted(1) == Catch::Approx(0.8).epsilon(1e-5));
CHECK(unweighted(2) == Catch::Approx(1.3).epsilon(1e-5));
CHECK(unweighted(3) == Catch::Approx(0.0).margin(1e-4));
const aare::FitModel<aare::model::Gaussian> weighted_model(
0, 100, 0.5, true, aare::Minimizer::Fumili);
const auto weighted =
aare::fit_pixel(weighted_model, x.view(), y.view(), y_err.view());
REQUIRE(weighted.size() == 7);
CHECK(weighted(0) == Catch::Approx(120.0).epsilon(1e-5));
CHECK(weighted(1) == Catch::Approx(0.8).epsilon(1e-5));
CHECK(weighted(2) == Catch::Approx(1.3).epsilon(1e-5));
for (ssize_t k = 3; k < 6; ++k)
CHECK(weighted(k) > 0.0);
CHECK(weighted(6) == Catch::Approx(0.0).margin(1e-4));
}
TEST_CASE("Fumili parameter errors agree with Migrad and Hesse", "[fit]") {
aare::NDArray<double, 1> x({n_points});
aare::NDArray<double, 1> y({n_points});
aare::NDArray<double, 1> y_err({n_points}, 2.0);
fill_gaussian(x, y, 120.0, 0.8, 1.3);
const aare::FitModel<aare::model::Gaussian> migrad(0, 100, 0.5, true,
aare::Minimizer::Migrad);
const aare::FitModel<aare::model::Gaussian> fumili(0, 100, 0.5, true,
aare::Minimizer::Fumili);
const auto with_migrad =
aare::fit_pixel(migrad, x.view(), y.view(), y_err.view());
const auto with_fumili =
aare::fit_pixel(fumili, x.view(), y.view(), y_err.view());
REQUIRE(with_migrad.size() == 7);
REQUIRE(with_fumili.size() == 7);
for (ssize_t k = 0; k < 3; ++k)
CHECK(with_fumili(k) == Catch::Approx(with_migrad(k)).epsilon(1e-5));
for (ssize_t k = 3; k < 6; ++k)
CHECK(with_fumili(k) == Catch::Approx(with_migrad(k)).epsilon(1e-2));
}
TEST_CASE("Fumili honours fixed parameters and limits", "[fit]") {
aare::NDArray<double, 1> x({n_points});
aare::NDArray<double, 1> y({n_points});
fill_gaussian(x, y, 120.0, 0.8, 1.3);
aare::FitModel<aare::model::Gaussian> model(0, 100, 0.5, false,
aare::Minimizer::Fumili);
model.FixParameter("mu", 0.8);
model.SetParLimits("sigma", 0.5, 5.0);
const auto result = aare::fit_pixel(model, x.view(), y.view());
REQUIRE(result.size() == 4);
CHECK(result(0) == Catch::Approx(120.0).epsilon(1e-5));
CHECK(result(1) == 0.8);
CHECK(result(2) == Catch::Approx(1.3).epsilon(1e-5));
CHECK(result(3) == Catch::Approx(0.0).margin(1e-4));
}
TEST_CASE("Fumili fits a rising S-curve", "[fit]") {
// f(x) = (p0 + p1*x) + 0.5*(1 + erf((x - mu)/(sqrt(2)*sigma))) * (A + C*(x
// - mu))
const std::vector<double> truth = {5.0, 0.1, 30.0, 4.0, 200.0, 0.5};
aare::NDArray<double, 1> x({n_points});
aare::NDArray<double, 1> y({n_points});
for (ssize_t i = 0; i < n_points; ++i) {
x(i) = static_cast<double>(i);
y(i) = aare::model::RisingScurve::eval(x(i), truth);
}
const aare::FitModel<aare::model::RisingScurve> model(
0, 200, 0.5, false, aare::Minimizer::Fumili);
const auto result = aare::fit_pixel(model, x.view(), y.view());
REQUIRE(result.size() == 7);
for (std::size_t k = 0; k < truth.size(); ++k)
CHECK(result(k) == Catch::Approx(truth[k]).epsilon(1e-3));
}
TEST_CASE("Fumili fits a Gaussian data cube in parallel", "[fit]") {
aare::NDArray<double, 1> x({n_points});
aare::NDArray<double, 1> values({n_points});
fill_gaussian(x, values, 80.0, -0.6, 0.9);
aare::NDArray<double, 3> y({2, 2, n_points});
aare::NDArray<double, 3> y_err({2, 2, n_points}, 1.0);
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);
}
}
aare::NDArray<double, 3> par({2, 2, 3});
aare::NDArray<double, 3> err({2, 2, 3});
aare::NDArray<double, 2> chi2({2, 2});
const aare::FitModel<aare::model::Gaussian> model(0, 100, 0.5, true,
aare::Minimizer::Fumili);
aare::fit_3d(model, x.view(), y.view(), y_err.view(), par.view(),
err.view(), chi2.view(), 2);
// Fumili stops once the estimated remaining chi2 decrease is below
// tolerance * 1e-4, which leaves deviations of order 1e-5 relative here.
for (ssize_t row = 0; row < 2; ++row) {
for (ssize_t col = 0; col < 2; ++col) {
CHECK(par(row, col, 0) == Catch::Approx(80.0).epsilon(1e-4));
CHECK(par(row, col, 1) == Catch::Approx(-0.6).epsilon(1e-4));
CHECK(par(row, col, 2) == Catch::Approx(0.9).epsilon(1e-4));
for (ssize_t k = 0; k < 3; ++k)
CHECK(err(row, col, k) > 0.0);
CHECK(chi2(row, col) == Catch::Approx(0.0).margin(1e-3));
}
}
}
TEST_CASE("Copying a FitModel keeps the minimizer", "[fit]") {
const aare::FitModel<aare::model::Pol1> original(0, 100, 0.5, false,
aare::Minimizer::Fumili);
const aare::FitModel<aare::model::Pol1> copied(original);
aare::FitModel<aare::model::Pol1> assigned;
assigned = original;
CHECK(copied.minimizer() == aare::Minimizer::Fumili);
CHECK(assigned.minimizer() == aare::Minimizer::Fumili);
}
TEST_CASE("Fitting with every parameter fixed returns the fixed values",
"[fit]") {
aare::NDArray<double, 1> x({n_points});
aare::NDArray<double, 1> y({n_points});
fill_gaussian(x, y, 120.0, 0.8, 1.3);
for (const auto minimizer :
{aare::Minimizer::Migrad, aare::Minimizer::Fumili}) {
aare::FitModel<aare::model::Gaussian> model(0, 100, 0.5, true,
minimizer);
model.FixParameter("A", 120.0);
model.FixParameter("mu", 0.8);
model.FixParameter("sigma", 1.3);
const auto result = aare::fit_pixel(model, x.view(), y.view());
REQUIRE(result.size() == 7);
CHECK(result(0) == 120.0);
CHECK(result(1) == 0.8);
CHECK(result(2) == 1.3);
CHECK(result(6) == Catch::Approx(0.0).margin(1e-12));
}
}