Skip to content

Commit 6cfe829

Browse files
committed
[RF][RS] Store fit results in the AsymptoticCalculator
The AsymptoticCalculator performed all its fits in a local helper that saved a RooFitResult only to read off the minimum NLL value and then discarded it. Users therefore had no way to check programmatically whether the underlying fits actually converged, even though the calculator happily reports a significance also when they did not. Keep the RooFitResult objects of the four fits (unconditional and conditional, on observed and Asimov data) as members of the calculator and expose them with the new getters GetFitResultUncondObs(), GetFitResultCondObs(), GetFitResultUncondAsimov() and GetFitResultCondAsimov(). The results are now saved also when the minimization failed, so that the minimizer status and EDM of a non-converged fit can be inspected. The stored unconditional results are updated when GetHypoTest() finds a better minimum in its refit fallback. Also document in the class description that the fits can already be steered via the ROOT::Math::MinimizerOptions defaults for strategy and tolerance, which was the second request in the ticket. Closes JIRA [ROOT-10066](https://its.cern.ch/jira/browse/ROOT-10066). 🤖 Done with the help of AI
1 parent fe04644 commit 6cfe829

4 files changed

Lines changed: 180 additions & 18 deletions

File tree

roofit/roostats/inc/RooStats/AsymptoticCalculator.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
#include "RooArgSet.h"
1616
#include "Rtypes.h"
1717

18+
#include <memory>
19+
1820
class RooArgList;
1921
class RooCategory;
22+
class RooFitResult;
2023
class RooRealVar;
2124
class RooPoisson;
2225
class RooProdPdf;
@@ -100,6 +103,29 @@ namespace RooStats {
100103
/// return best fit value for all parameters
101104
const RooArgSet & GetBestFitParams() const { return fBestFitPoi; }
102105

106+
/// Result of the unconditional fit to the observed data, performed by
107+
/// Initialize() (updated if GetHypoTest() finds a better minimum).
108+
/// Returns nullptr if the fit was skipped or has not been run yet.
109+
/// The calculator keeps ownership of the returned object.
110+
const RooFitResult *GetFitResultUncondObs() const { return fFitResultUncondObs.get(); }
111+
/// Result of the conditional fit to the observed data with the POI fixed
112+
/// to the tested value, from the last call to GetHypoTest().
113+
/// Returns nullptr if the fit was skipped or has not been run yet.
114+
/// The calculator keeps ownership of the returned object.
115+
const RooFitResult *GetFitResultCondObs() const { return fFitResultCondObs.get(); }
116+
/// Result of the fit to the Asimov data set with the POI fixed to the
117+
/// value of the alternate-model snapshot, performed by Initialize(). Since
118+
/// the Asimov data set is generated at that POI value, this corresponds to
119+
/// the unconditional minimum (updated if GetHypoTest() finds a better
120+
/// minimum). Returns nullptr if the fit was skipped or has not been run
121+
/// yet. The calculator keeps ownership of the returned object.
122+
const RooFitResult *GetFitResultUncondAsimov() const { return fFitResultUncondAsimov.get(); }
123+
/// Result of the conditional fit to the Asimov data set with the POI
124+
/// fixed to the tested value, from the last call to GetHypoTest().
125+
/// Returns nullptr if the fit was skipped or has not been run yet.
126+
/// The calculator keeps ownership of the returned object.
127+
const RooFitResult *GetFitResultCondAsimov() const { return fFitResultCondAsimov.get(); }
128+
103129
static void SetPrintLevel(int level);
104130

105131
private:
@@ -117,6 +143,11 @@ namespace RooStats {
117143
mutable RooArgSet fBestFitPoi; ///< snapshot of best fitted POI values
118144
mutable RooArgSet fBestFitParams; ///< snapshot of all best fitted Parameter values
119145

146+
mutable std::unique_ptr<RooFitResult> fFitResultUncondObs; ///<! result of unconditional fit to observed data
147+
mutable std::unique_ptr<RooFitResult> fFitResultCondObs; ///<! result of conditional fit to observed data
148+
mutable std::unique_ptr<RooFitResult> fFitResultUncondAsimov; ///<! result of fit to Asimov data at the alt POI
149+
mutable std::unique_ptr<RooFitResult> fFitResultCondAsimov; ///<! result of conditional fit to Asimov data
150+
120151
ClassDefOverride(AsymptoticCalculator,0)
121152
};
122153
}

roofit/roostats/src/AsymptoticCalculator.cxx

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,18 @@ If more than one POI exists, only the first one is used.
3535
The calculator can generate Asimov datasets from two kinds of PDFs:
3636
- "Counting" distributions: RooPoisson, RooGaussian, or products of RooPoissons.
3737
- Extended, *i.e.* number of events can be read off from extended likelihood term.
38-
*/
3938
39+
The fits performed by the calculator can be steered with the global default
40+
minimizer options, *e.g.* via ROOT::Math::MinimizerOptions::SetDefaultStrategy()
41+
and ROOT::Math::MinimizerOptions::SetDefaultTolerance() (the tolerance is
42+
clamped to a minimum value of 1). The RooFitResult objects of the fits are
43+
retrievable after calling GetHypoTest() via GetFitResultUncondObs(),
44+
GetFitResultCondObs(), GetFitResultUncondAsimov() and GetFitResultCondAsimov(),
45+
so quantities like the minimizer status or the EDM at the minimum can be
46+
inspected, for example to cross-check a fit that did not converge. Note that
47+
when the calculator is driven by the HypoTestInverter, the stored conditional
48+
fit results correspond to the last scanned point.
49+
*/
4050

4151
#include "RooStats/AsymptoticCalculator.h"
4252
#include "RooStats/ModelConfig.h"
@@ -84,7 +94,8 @@ int &fgPrintLevel()
8494
}
8595

8696
// Forward declaration.
87-
double EvaluateNLL(RooStats::ModelConfig const &modelConfig, RooAbsData &data, const RooArgSet *poiSet = nullptr);
97+
double EvaluateNLL(RooStats::ModelConfig const &modelConfig, RooAbsData &data, const RooArgSet *poiSet = nullptr,
98+
std::unique_ptr<RooFitResult> *fitResult = nullptr);
8899

89100
} // namespace
90101

@@ -196,11 +207,19 @@ bool AsymptoticCalculator::Initialize() const {
196207
fBestFitPoi.removeAll();
197208
fBestFitParams.removeAll();
198209
fAsimovGlobObs.removeAll();
210+
fFitResultUncondObs.reset();
211+
fFitResultCondObs.reset();
212+
fFitResultUncondAsimov.reset();
213+
fFitResultCondAsimov.reset();
199214

200215
// evaluate the unconditional nll for the full model on the observed data
201216
if (verbose >= 0)
202217
oocoutP(nullptr,Eval) << "AsymptoticCalculator::Initialize - Find best unconditional NLL on observed data" << std::endl;
203-
fNLLObs = EvaluateNLL(*GetNullModel(), data);
218+
fNLLObs = EvaluateNLL(*GetNullModel(), data, nullptr, &fFitResultUncondObs);
219+
if (fFitResultUncondObs) {
220+
fFitResultUncondObs->SetName("fitResultUncondObs");
221+
fFitResultUncondObs->SetTitle("Unconditional fit to observed data");
222+
}
204223
// fill also snapshot of best poi
205224
poi->snapshot(fBestFitPoi);
206225
RooRealVar * muBest = dynamic_cast<RooRealVar*>(fBestFitPoi.first());
@@ -283,7 +302,11 @@ bool AsymptoticCalculator::Initialize() const {
283302
<< muAlt->GetName() << " ) = " << muAlt->getVal() << std::endl;
284303
}
285304

286-
fNLLAsimov = EvaluateNLL(*GetNullModel(), *fAsimovData, &poiAlt );
305+
fNLLAsimov = EvaluateNLL(*GetNullModel(), *fAsimovData, &poiAlt, &fFitResultUncondAsimov);
306+
if (fFitResultUncondAsimov) {
307+
fFitResultUncondAsimov->SetName("fitResultUncondAsimov");
308+
fFitResultUncondAsimov->SetTitle("Fit to Asimov data with POI fixed to the alt-model snapshot");
309+
}
287310
// for unconditional fit
288311
//fNLLAsimov = EvaluateNLL( *nullPdf, *fAsimovData);
289312
//poi->Print("v");
@@ -300,10 +323,14 @@ bool AsymptoticCalculator::Initialize() const {
300323

301324
namespace {
302325

303-
double EvaluateNLL(RooStats::ModelConfig const& modelConfig, RooAbsData& data, const RooArgSet *poiSet)
326+
double EvaluateNLL(RooStats::ModelConfig const &modelConfig, RooAbsData &data, const RooArgSet *poiSet,
327+
std::unique_ptr<RooFitResult> *fitResult)
304328
{
305329
int verbose = fgPrintLevel();
306330

331+
if (fitResult)
332+
fitResult->reset();
333+
307334
RooAbsPdf &pdf = *modelConfig.GetPdf();
308335

309336
RooFit::MsgLevel msglevel = RooMsgService::instance().globalKillBelow();
@@ -415,13 +442,12 @@ double EvaluateNLL(RooStats::ModelConfig const& modelConfig, RooAbsData& data, c
415442
}
416443
}
417444

418-
std::unique_ptr<RooFitResult> result;
445+
// save the fit result also in case of failure, so that the status of a
446+
// non-converged fit can be inspected by the user
447+
std::unique_ptr<RooFitResult> result{minim.save()};
419448

420449
// ignore errors in Hesse or in Improve and also when matrix was made pos def (status returned = 1)
421-
if (status >= 0) {
422-
result = std::unique_ptr<RooFitResult>{minim.save()};
423-
}
424-
if (result){
450+
if (status >= 0 && result) {
425451
if (RooStats::NLLOffsetMode() != "initial") {
426452
val = result->minNll();
427453
} else {
@@ -431,12 +457,14 @@ double EvaluateNLL(RooStats::ModelConfig const& modelConfig, RooAbsData& data, c
431457
if (!previous) RooAbsReal::setHideOffset(false) ;
432458
}
433459

434-
}
435-
else {
460+
} else {
436461
oocoutE(nullptr,Fitting) << "FIT FAILED !- return a NaN NLL " << std::endl;
437462
val = TMath::QuietNaN();
438463
}
439464

465+
if (fitResult)
466+
*fitResult = std::move(result);
467+
440468
minim.optimizeConst(false);
441469
}
442470

@@ -528,7 +556,12 @@ HypoTestResult* AsymptoticCalculator::GetHypoTest() const {
528556
}
529557

530558
// evaluate the conditional NLL on the observed data for the snapshot value
531-
double condNLL = EvaluateNLL(*GetNullModel(), const_cast<RooAbsData&>(*GetData()), &poiTest);
559+
double condNLL = EvaluateNLL(*GetNullModel(), const_cast<RooAbsData &>(*GetData()), &poiTest, &fFitResultCondObs);
560+
if (fFitResultCondObs) {
561+
fFitResultCondObs->SetName("fitResultCondObs");
562+
fFitResultCondObs->SetTitle(
563+
TString::Format("Conditional fit to observed data for %s = %g", muTest->GetName(), muTest->getVal()));
564+
}
532565

533566
double qmu = 2.*(condNLL - fNLLObs);
534567

@@ -550,14 +583,20 @@ HypoTestResult* AsymptoticCalculator::GetHypoTest() const {
550583
<< "AsymptoticCalculator: unconditional fit failed before - retry to do it now " << std::endl;
551584
}
552585

553-
double nll = EvaluateNLL(*GetNullModel(), const_cast<RooAbsData&>(*GetData()));
586+
std::unique_ptr<RooFitResult> refitResult;
587+
double nll = EvaluateNLL(*GetNullModel(), const_cast<RooAbsData &>(*GetData()), nullptr, &refitResult);
554588

555589
if (nll < fNLLObs || (TMath::IsNaN(fNLLObs) && !TMath::IsNaN(nll) ) ) {
556590
oocoutW(nullptr,Minimization) << "AsymptoticCalculator: Found a better unconditional minimum "
557591
<< " old NLL = " << fNLLObs << " old muHat " << muHat->getVal() << std::endl;
558592

559593
// update values
560594
fNLLObs = nll;
595+
if (refitResult) {
596+
fFitResultUncondObs = std::move(refitResult);
597+
fFitResultUncondObs->SetName("fitResultUncondObs");
598+
fFitResultUncondObs->SetTitle("Unconditional fit to observed data");
599+
}
561600
const RooArgSet * poi = GetNullModel()->GetParametersOfInterest();
562601
assert(poi);
563602
fBestFitPoi.removeAll();
@@ -612,8 +651,12 @@ HypoTestResult* AsymptoticCalculator::GetHypoTest() const {
612651

613652
if (verbose > 0) oocoutP(nullptr,Eval) << "AsymptoticCalculator::GetHypoTest -- Find best conditional NLL on ASIMOV data set .... " << std::endl;
614653

615-
double condNLL_A = EvaluateNLL(*GetNullModel(), *fAsimovData, &poiTest);
616-
654+
double condNLL_A = EvaluateNLL(*GetNullModel(), *fAsimovData, &poiTest, &fFitResultCondAsimov);
655+
if (fFitResultCondAsimov) {
656+
fFitResultCondAsimov->SetName("fitResultCondAsimov");
657+
fFitResultCondAsimov->SetTitle(
658+
TString::Format("Conditional fit to Asimov data for %s = %g", muTest->GetName(), muTest->getVal()));
659+
}
617660

618661
double qmu_A = 2.*(condNLL_A - fNLLAsimov );
619662

@@ -632,14 +675,20 @@ HypoTestResult* AsymptoticCalculator::GetHypoTest() const {
632675
<< std::endl;
633676
}
634677

635-
double nll = EvaluateNLL(*GetNullModel(), *fAsimovData);
678+
std::unique_ptr<RooFitResult> refitResult;
679+
double nll = EvaluateNLL(*GetNullModel(), *fAsimovData, nullptr, &refitResult);
636680

637681
if (nll < fNLLAsimov || (TMath::IsNaN(fNLLAsimov) && !TMath::IsNaN(nll) )) {
638682
oocoutW(nullptr,Minimization) << "AsymptoticCalculator: Found a better unconditional minimum for Asimov data set"
639683
<< " old NLL = " << fNLLAsimov << std::endl;
640684

641685
// update values
642686
fNLLAsimov = nll;
687+
if (refitResult) {
688+
fFitResultUncondAsimov = std::move(refitResult);
689+
fFitResultUncondAsimov->SetName("fitResultUncondAsimov");
690+
fFitResultUncondAsimov->SetTitle("Unconditional fit to Asimov data");
691+
}
643692

644693
oocoutW(nullptr,Minimization) << "AsymptoticCalculator: New minimum found for "
645694
<< " NLL = " << fNLLAsimov << std::endl;

roofit/roostats/test/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
ROOT_ADD_GTEST(testAsymptoticCalculator testAsymptoticCalculator.cxx LIBRARIES RooStats)
1+
ROOT_ADD_GTEST(testAsymptoticCalculator testAsymptoticCalculator.cxx LIBRARIES RooStats ROOT::TestSupport)
22
ROOT_ADD_GTEST(testBayesianCalculator testBayesianCalculator.cxx LIBRARIES RooStats)
33
ROOT_ADD_GTEST(testHypoTestInvResult testHypoTestInvResult.cxx
44
LIBRARIES RooStats

roofit/roostats/test/testAsymptoticCalculator.cxx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Author: Jonas Rembser, CERN 01/2025
22

33
#include "RooDataSet.h"
4+
#include "RooFitResult.h"
45
#include "RooMultiVarGaussian.h"
56
#include "RooRealVar.h"
67
#include "RooWorkspace.h"
@@ -9,6 +10,7 @@
910
#include "RooStats/ModelConfig.h"
1011

1112
#include "Math/ProbFuncMathCore.h"
13+
#include "ROOT/TestSupport.hxx"
1214

1315
#include "gtest/gtest.h"
1416

@@ -147,3 +149,83 @@ TEST(AsymptoticCalculator, CountingAsimovDataSetFloatingParams)
147149
ws.var("mean")->setConstant(true);
148150
checkAsimov("gauss1", 20.0);
149151
}
152+
153+
// Check that the fit results of the fits performed in Initialize() and
154+
// GetHypoTest() are stored and can be accessed by the user (JIRA ROOT-10066).
155+
TEST(AsymptoticCalculator, StoredFitResults)
156+
{
157+
using namespace RooStats;
158+
159+
// On/off Poisson counting model: signal region with s + b expected events,
160+
// control region constraining the background via tau * b.
161+
RooWorkspace ws;
162+
ws.factory("Poisson::px(x[150, 0, 500], sum::splusb(s[0, 0, 100], b[100, 0, 300]))");
163+
ws.factory("Poisson::py(y[100, 0, 500], prod::taub(tau[1.0], b))");
164+
ws.factory("PROD::model(px, py)");
165+
166+
RooRealVar &s = *ws.var("s");
167+
RooArgSet obs{*ws.var("x"), *ws.var("y")};
168+
169+
RooDataSet data{"data", "data", obs};
170+
data.add(obs);
171+
172+
ModelConfig sbModel{"sbModel", &ws};
173+
sbModel.SetPdf(*ws.pdf("model"));
174+
sbModel.SetObservables(obs);
175+
sbModel.SetParametersOfInterest(RooArgSet{s});
176+
sbModel.SetNuisanceParameters(RooArgSet{*ws.var("b")});
177+
s.setVal(50.0);
178+
sbModel.SetSnapshot(RooArgSet{s});
179+
180+
std::unique_ptr<ModelConfig> bModel{static_cast<ModelConfig *>(sbModel.Clone("bModel"))};
181+
s.setVal(0.0);
182+
bModel->SetSnapshot(RooArgSet{s});
183+
184+
// Some of the fits start at parameter values that already correspond to the
185+
// minimum, in which case Minuit2 emits a harmless line-search warning.
186+
ROOT::TestSupport::CheckDiagsRAII checkDiag;
187+
checkDiag.optionalDiag(kWarning, "Minuit2", "VariableMetricBuilder No improvement in line search", false);
188+
189+
AsymptoticCalculator calc{data, *bModel, sbModel};
190+
calc.SetOneSided(true);
191+
192+
// Before running the hypothesis test, no fit results are available.
193+
EXPECT_EQ(calc.GetFitResultCondObs(), nullptr);
194+
EXPECT_EQ(calc.GetFitResultCondAsimov(), nullptr);
195+
196+
std::unique_ptr<HypoTestResult> result{calc.GetHypoTest()};
197+
ASSERT_NE(result, nullptr);
198+
199+
const RooFitResult *uncondObs = calc.GetFitResultUncondObs();
200+
const RooFitResult *condObs = calc.GetFitResultCondObs();
201+
const RooFitResult *uncondAsimov = calc.GetFitResultUncondAsimov();
202+
const RooFitResult *condAsimov = calc.GetFitResultCondAsimov();
203+
204+
ASSERT_NE(uncondObs, nullptr);
205+
ASSERT_NE(condObs, nullptr);
206+
ASSERT_NE(uncondAsimov, nullptr);
207+
ASSERT_NE(condAsimov, nullptr);
208+
209+
EXPECT_EQ(uncondObs->status(), 0);
210+
EXPECT_EQ(condObs->status(), 0);
211+
EXPECT_EQ(uncondAsimov->status(), 0);
212+
EXPECT_EQ(condAsimov->status(), 0);
213+
214+
// The best-fit POI value from the unconditional fit result must be
215+
// consistent with the one stored by the calculator.
216+
auto *sFitUncond = static_cast<RooRealVar *>(uncondObs->floatParsFinal().find("s"));
217+
ASSERT_NE(sFitUncond, nullptr);
218+
EXPECT_DOUBLE_EQ(sFitUncond->getVal(), calc.GetMuHat()->getVal());
219+
220+
// In the conditional fits the POI is fixed to the tested value from the
221+
// null-model snapshot, so it appears in the constant parameter list.
222+
auto *sFitCond = static_cast<RooRealVar *>(condObs->constPars().find("s"));
223+
ASSERT_NE(sFitCond, nullptr);
224+
EXPECT_DOUBLE_EQ(sFitCond->getVal(), 50.0);
225+
226+
// The profile likelihood ratio test statistic reconstructed from the stored
227+
// fit results must be non-negative, up to the same numerical tolerance that
228+
// the calculator itself uses for qmu.
229+
EXPECT_GE(condObs->minNll() - uncondObs->minNll(), -1.e-3);
230+
EXPECT_GE(condAsimov->minNll() - uncondAsimov->minNll(), -1.e-3);
231+
}

0 commit comments

Comments
 (0)