Skip to content

Commit 05d4de3

Browse files
committed
[RF] Always save generated parameters in RooMCStudy
Fix three related problems around RooMCStudy::genParDataSet(), reported in GitHub issue #9490 and its discussion thread: 1. The generated parameter values were only recorded when internal constraints were used via Constrain(), despite the documentation promising that they are saved. The _genParData dataset is now always created and filled with one entry per generated toy. 2. Parameters constrained with ExternalConstraints() were not sampled from the constraint p.d.f.s for each toy, because only RooAbsPdf::getAllConstraints() was consulted, which cannot see constraint terms that are not part of the model. The external constraint p.d.f.s are now included in the per-toy sampling, with the constrained parameters found by intersecting the constraint observables with the model parameters. Passing the constraints inside FitOptions() remains available to apply them in the fit only. 3. When some toy fits failed, merging the generated parameter values into the fit parameter dataset failed with "ERROR: datasets have different size", because _genParData has one entry per generated toy while _fitParData only gets entries for converged fits. The merge now goes through a copy that only contains the toys whose fit converged, so the "<name>_gen" columns stay row-aligned with fitParDataSet(), while genParDataSet() keeps the entries of all generated toys (including the failed ones, so no information is dropped). Further consequences of the implementation: - genParDataSet() columns are no longer renamed in place to "<name>_gen"; only the merged copies are. This also fixes repeated runs accumulating "_gen" suffixes. - The merge only happens for runs that both generate and fit, and only when the parameters are sampled from constraint p.d.f.s. This avoids clobbering the "<name>_gen" columns published by RooRandomizeParamMCSModule and keeps the fitParDataSet() schema unchanged for unconstrained studies. Fit-only runs previously merged the columns in reverse toy order, silently producing wrong pulls; they now emit a warning instead. - _genParData is filled after the processBeforeGen() module hook, so it records the values actually used for generation. - generate() and generateAndFit() reset _genParData like the other result containers. Closes #9490 🤖 Done with the help of AI
1 parent 2e55abd commit 05d4de3

4 files changed

Lines changed: 242 additions & 25 deletions

File tree

roofit/roofitcore/inc/RooMCStudy.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,11 @@ class RooMCStudy : public TNamed {
6060
const RooFitResult* fitResult(Int_t sampleNum) const ;
6161
RooAbsData* genData(Int_t sampleNum) const ;
6262
const RooDataSet& fitParDataSet() ;
63-
/// Return dataset with generator parameters for each toy. When constraints are used these
64-
/// may generally not be the same as the fitted parameters.
63+
/// Return dataset with the generator parameter values used for each toy, including any
64+
/// modification by constraint-p.d.f. sampling or by study modules. When constraints are
65+
/// used, the values are sampled from the constraint p.d.f.s for each toy and thus generally
66+
/// differ from the fitted parameter values. Unlike fitParDataSet(), this dataset has one
67+
/// entry per generated toy, including toys for which the fit did not converge.
6568
const RooDataSet* genParDataSet() const {
6669
return _genParData.get();
6770
}

roofit/roofitcore/src/RooMCStudy.cxx

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ fitting the PDF to data and accumulating the fit statistics.
9595
<tr><td> Verbose(bool flag) <td> Activate informational messages in event generation phase
9696
<tr><td> Extended(bool flag) <td> Determine number of events for each sample anew from a Poisson distribution
9797
<tr><td> Constrain(const RooArgSet& pars) <td> Apply internal constraints on given parameters in fit and sample constrained parameter values from constraint p.d.f for each toy.
98+
<tr><td> ExternalConstraints(const RooArgSet& cpdfs) <td> Apply given external constraint p.d.f.s in fit and sample values of the parameters they constrain from them for each toy.
99+
To apply the constraints in the fit only, without the per-toy sampling, pass them inside FitOptions() instead.
98100
<tr><td> ProtoData(const RooDataSet&, bool randOrder)
99101
<td> Prototype data for the event generation. If the randOrder flag is set, the order of the dataset will be re-randomized for each generation
100102
cycle to protect against systematic biases if the number of generated events does not exactly match the number of events in the prototype dataset
@@ -169,28 +171,31 @@ RooMCStudy::RooMCStudy(const RooAbsPdf& model, const RooArgSet& observables,
169171
_fitOptList.Add(RooFit::ExternalConstraints(*extCons).Clone()) ;
170172
}
171173

172-
// Make list of all constraints
174+
// Make list of all constraints and of the parameters they constrain
173175
RooArgSet allConstraints ;
174176
RooArgSet consPars ;
175177
if (cPars) {
176178
if (std::unique_ptr<RooArgSet> constraints{model.getAllConstraints(observables,*cPars,true)}) {
177179
allConstraints.add(*constraints) ;
178180
}
181+
consPars.add(*cPars) ;
182+
}
183+
if (extCons) {
184+
// External constraint p.d.f.s are not part of the model, so the parameters
185+
// they constrain are found among their observables instead
186+
allConstraints.add(*extCons) ;
187+
RooArgSet params;
188+
model.getParameters(&observables, params);
189+
for (RooAbsArg const* con : *extCons) {
190+
RooArgSet cparams;
191+
con->getObservables(&params, cparams);
192+
consPars.add(cparams, /*silent=*/true) ;
193+
}
179194
}
180195

181196
// Construct constraint p.d.f
182197
if (!allConstraints.empty()) {
183198
_constrPdf = std::make_unique<RooProdPdf>("mcs_constr_prod","RooMCStudy constraints product",allConstraints);
184-
185-
if (cPars) {
186-
consPars.add(*cPars) ;
187-
} else {
188-
RooArgSet params;
189-
model.getParameters(&observables, params);
190-
RooArgSet cparams;
191-
_constrPdf->getObservables(&params, cparams);
192-
consPars.add(cparams) ;
193-
}
194199
_constrGenContext.reset(_constrPdf->genContext(consPars,nullptr,nullptr,_verboseGen));
195200

196201
_perExptGenParams = true ;
@@ -261,9 +266,7 @@ RooMCStudy::RooMCStudy(const RooAbsPdf& model, const RooArgSet& observables,
261266
tmp2.setAttribAll("StoreError",false) ;
262267
tmp2.setAttribAll("StoreAsymError",false) ;
263268

264-
if (_perExptGenParams) {
265-
_genParData = std::make_unique<RooDataSet>("genParData","Generated Parameters dataset",_genParams);
266-
}
269+
_genParData = std::make_unique<RooDataSet>("genParData","Generated Parameters dataset",_genParams);
267270

268271
// Append proto variables to allDependents
269272
if (_genProtoData) {
@@ -309,6 +312,11 @@ void RooMCStudy::addModule(RooAbsMCStudyModule& module)
309312
/// If keepGenData is set, all generated data sets will be kept in memory and can be accessed
310313
/// later via genData().
311314
///
315+
/// When generating, the generator parameter values used for each sample are recorded in the
316+
/// dataset returned by genParDataSet(). When constraints are used and the run both generates
317+
/// and fits, the sampled parameter values are in addition merged into the fit parameter
318+
/// dataset as `<name>_gen` columns for each toy whose fit converged.
319+
///
312320
/// When generating, data sets will be written out in ascii form if the pattern string is supplied
313321
/// The pattern, which is a template for snprintf, should look something like "data/toymc_%04d.dat"
314322
/// and should contain one integer field that encodes the sample serial number.
@@ -329,8 +337,25 @@ bool RooMCStudy::run(bool doGenerate, bool DoFit, Int_t nSamples, Int_t nEvtPerS
329337
mod->initializeRun(nSamples) ;
330338
}
331339

340+
if (DoFit && !doGenerate && _perExptGenParams) {
341+
coutW(Generation) << "RooMCStudy::run: WARNING fitting previously generated samples in a separate run:"
342+
" the per-toy sampled generator parameters are not merged into the fit parameter dataset,"
343+
" so pulls are computed with respect to the initial parameter values instead of the sampled ones" << std::endl ;
344+
}
345+
332346
int prescale = nSamples>100 ? int(nSamples/100) : 1 ;
333347

348+
// Generator parameter values of the toys whose fit converged, filled in the
349+
// same order as _fitParData so that the two datasets can be merged after the
350+
// loop. Only done when the parameters are sampled from constraint p.d.f.s:
351+
// otherwise the values are the constant initial ones, and study modules like
352+
// RooRandomizeParamMCSModule publish their own "<name>_gen" columns that
353+
// must not be overwritten by the merge.
354+
std::unique_ptr<RooDataSet> genParDataForMerge;
355+
if (doGenerate && DoFit && _perExptGenParams && _genParData) {
356+
genParDataForMerge = std::make_unique<RooDataSet>("genParDataForMerge","Generated Parameters dataset",*_genParData->get());
357+
}
358+
334359
while(nSamples--) {
335360

336361
if (nSamples%prescale==0) {
@@ -356,16 +381,17 @@ bool RooMCStudy::run(bool doGenerate, bool DoFit, Int_t nSamples, Int_t nEvtPerS
356381
_genParams.assign(*std::unique_ptr<RooDataSet>{_constrGenContext->generate(1)}->get());
357382
}
358383

359-
// Save generated parameters if required
360-
if (_genParData) {
361-
_genParData->add(_genParams) ;
362-
}
363-
364384
// Call module before-generation hook
365385
for (RooAbsMCStudyModule *mod : _modList) {
366386
mod->processBeforeGen(nSamples) ;
367387
}
368388

389+
// Save the generator parameters used for this toy, including any
390+
// modification applied by the study modules above
391+
if (_genParData) {
392+
_genParData->add(_genParams) ;
393+
}
394+
369395
if (_binGenData) {
370396

371397
// Calculate the number of (extended) events for this run
@@ -436,6 +462,14 @@ bool RooMCStudy::run(bool doGenerate, bool DoFit, Int_t nSamples, Int_t nEvtPerS
436462
bool fitOk = true;
437463
if (DoFit) fitOk = !fitSample(_genSample) ;
438464

465+
// Keep the generator parameters of this toy for merging into the fit
466+
// parameter dataset. Only converged fits get an entry in _fitParData, so
467+
// the toys with failed fits have to be skipped here as well. The values
468+
// are taken from _genParData because the fit changes the parameters.
469+
if (genParDataForMerge && _genParData && fitOk) {
470+
genParDataForMerge->add(*_genParData->get(_genParData->numEntries()-1)) ;
471+
}
472+
439473
// Call module between generation and fitting hook
440474
for (RooAbsMCStudyModule *mod : _modList) {
441475
mod->processAfterFit(fitOk) ;
@@ -468,12 +502,14 @@ bool RooMCStudy::run(bool doGenerate, bool DoFit, Int_t nSamples, Int_t nEvtPerS
468502

469503
_canAddFitResults = false ;
470504

471-
if (_genParData) {
472-
for(RooAbsArg * arg : *_genParData->get()) {
473-
_genParData->changeObservableName(arg->GetName(),(std::string(arg->GetName()) + "_gen").c_str());
505+
if (genParDataForMerge) {
506+
// Append the generator parameter values as additional "<name>_gen"
507+
// columns to the fit parameter dataset
508+
for(RooAbsArg * arg : *genParDataForMerge->get()) {
509+
genParDataForMerge->changeObservableName(arg->GetName(),(std::string(arg->GetName()) + "_gen").c_str());
474510
}
475511

476-
_fitParData->merge(_genParData.get());
512+
_fitParData->merge(genParDataForMerge.get());
477513
}
478514

479515
if (DoFit) calcPulls() ;
@@ -506,6 +542,7 @@ bool RooMCStudy::generateAndFit(Int_t nSamples, Int_t nEvtPerSample, bool keepGe
506542
_fitResList.Delete() ; // even though the fit results are owned by gROOT, we still want to scratch them here.
507543
_genDataList.Delete() ;
508544
_fitParData->reset() ;
545+
if (_genParData) _genParData->reset() ;
509546

510547
return run(true,true,nSamples,nEvtPerSample,keepGenData,asciiFilePat) ;
511548
}
@@ -526,6 +563,7 @@ bool RooMCStudy::generate(Int_t nSamples, Int_t nEvtPerSample, bool keepGenData,
526563
{
527564
// Clear any previous data in memory
528565
_genDataList.Delete() ;
566+
if (_genParData) _genParData->reset() ;
529567

530568
return run(true,false,nSamples,nEvtPerSample,keepGenData,asciiFilePat) ;
531569
}

roofit/roofitcore/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ if(clad)
8282
endif()
8383
ROOT_ADD_GTEST(testNaNPacker testNaNPacker.cxx LIBRARIES RooFitCore)
8484
ROOT_ADD_GTEST(testRooExtendedBinding testRooExtendedBinding.cxx LIBRARIES RooFitCore RooFit)
85+
ROOT_ADD_GTEST(testRooMCStudy testRooMCStudy.cxx LIBRARIES RooFitCore RooFit)
8586
ROOT_ADD_GTEST(testRooMinimizer testRooMinimizer.cxx LIBRARIES RooFitCore RooFit)
8687
ROOT_ADD_GTEST(testRooMulti testRooMulti.cxx LIBRARIES RooFitCore RooFit)
8788
ROOT_ADD_GTEST(testRooRombergIntegrator testRooRombergIntegrator.cxx LIBRARIES MathCore RooFitCore)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Tests for RooMCStudy
2+
// Authors: Jonas Rembser, CERN 2026
3+
4+
#include <RooAbsPdf.h>
5+
#include <RooArgSet.h>
6+
#include <RooDataSet.h>
7+
#include <RooHelpers.h>
8+
#include <RooMCStudy.h>
9+
#include <RooRandom.h>
10+
#include <RooRealVar.h>
11+
#include <RooWorkspace.h>
12+
13+
#include "gtest_wrapper.h"
14+
15+
#include <algorithm>
16+
#include <stdexcept>
17+
#include <string>
18+
#include <vector>
19+
20+
namespace {
21+
22+
void fillModel(RooWorkspace &ws)
23+
{
24+
ws.factory("x[-10, 10]");
25+
ws.factory("Gaussian::pdf1(x, m[-1, 1], s[5, 10])");
26+
ws.factory("Gaussian::pdf2(x, m, s2[1, 3])");
27+
ws.factory("SUM::pdf(N1[0, 100] * pdf1, N2[0, 100] * pdf2)");
28+
}
29+
30+
std::vector<double> getColumn(RooDataSet const &data, const char *name)
31+
{
32+
auto *var = static_cast<RooRealVar const *>(data.get()->find(name));
33+
if (var == nullptr) {
34+
throw std::runtime_error(std::string{"dataset has no column named \""} + name + "\"");
35+
}
36+
std::vector<double> out;
37+
out.reserve(data.numEntries());
38+
for (int i = 0; i < data.numEntries(); ++i) {
39+
data.get(i);
40+
out.push_back(var->getVal());
41+
}
42+
return out;
43+
}
44+
45+
} // namespace
46+
47+
/// Covers GitHub issue #9490: the generated parameter values must be saved
48+
/// also when no constraints are used.
49+
TEST(RooMCStudy, GenParDataSetNoConstraints)
50+
{
51+
RooRandom::randomGenerator()->SetSeed(4357);
52+
RooHelpers::LocalChangeMsgLevel chmsglvl{RooFit::WARNING};
53+
54+
RooWorkspace ws;
55+
fillModel(ws);
56+
57+
RooMCStudy mcstudy{*ws.pdf("pdf"), *ws.var("x"), RooFit::Silence()};
58+
mcstudy.generate(3, 100, true);
59+
60+
RooDataSet const *genParData = mcstudy.genParDataSet();
61+
ASSERT_NE(genParData, nullptr);
62+
EXPECT_EQ(genParData->numEntries(), 3);
63+
// The columns keep the original parameter names
64+
EXPECT_NE(genParData->get()->find("s"), nullptr);
65+
}
66+
67+
/// Covers GitHub issue #9490: parameters constrained by external constraint
68+
/// p.d.f.s must be sampled from them for each toy, like for internal
69+
/// constraints.
70+
TEST(RooMCStudy, GenParDataSetExternalConstraints)
71+
{
72+
RooRandom::randomGenerator()->SetSeed(4357);
73+
RooHelpers::LocalChangeMsgLevel chmsglvl{RooFit::WARNING};
74+
75+
RooWorkspace ws;
76+
fillModel(ws);
77+
ws.factory("Gaussian::constraint(s, cm[7], cs[0.5])");
78+
RooArgSet extCons{*ws.pdf("constraint")};
79+
80+
RooMCStudy mcstudy{*ws.pdf("pdf"), *ws.var("x"), RooFit::ExternalConstraints(extCons), RooFit::Silence()};
81+
const int nToys = 20;
82+
mcstudy.generate(nToys, 100, true);
83+
84+
RooDataSet const *genParData = mcstudy.genParDataSet();
85+
ASSERT_NE(genParData, nullptr);
86+
ASSERT_EQ(genParData->numEntries(), nToys);
87+
88+
std::vector<double> svals = getColumn(*genParData, "s");
89+
double smin = svals[0];
90+
double smax = svals[0];
91+
double ssum = 0.0;
92+
for (double v : svals) {
93+
smin = std::min(smin, v);
94+
smax = std::max(smax, v);
95+
ssum += v;
96+
}
97+
// The values of "s" are sampled from Gaussian(s | 7, 0.5) for each toy
98+
EXPECT_GT(smax, smin);
99+
EXPECT_NEAR(ssum / nToys, 7.0, 1.0);
100+
}
101+
102+
/// The internal-constraints behavior that worked before GitHub issue #9490
103+
/// must be unchanged: per-toy sampled parameters in genParDataSet, and
104+
/// "<name>_gen" columns merged into fitParDataSet.
105+
TEST(RooMCStudy, GenParDataSetInternalConstraints)
106+
{
107+
RooRandom::randomGenerator()->SetSeed(4357);
108+
RooHelpers::LocalChangeMsgLevel chmsglvl{RooFit::WARNING};
109+
110+
RooWorkspace ws;
111+
fillModel(ws);
112+
ws.factory("Gaussian::constraint(s, cm[7], cs[0.5])");
113+
ws.factory("PROD::prodpdf({pdf,constraint})");
114+
115+
const int nToys = 3;
116+
RooMCStudy mcstudy{*ws.pdf("prodpdf"), *ws.var("x"), RooFit::Constrain(*ws.var("s")), RooFit::Silence(),
117+
RooFit::FitOptions(RooFit::PrintLevel(-1))};
118+
mcstudy.generateAndFit(nToys, 200);
119+
120+
RooDataSet const *genParData = mcstudy.genParDataSet();
121+
ASSERT_NE(genParData, nullptr);
122+
EXPECT_EQ(genParData->numEntries(), nToys);
123+
EXPECT_NE(genParData->get()->find("s"), nullptr);
124+
EXPECT_NE(mcstudy.fitParDataSet().get()->find("s_gen"), nullptr);
125+
}
126+
127+
/// Covers the fitParData/genParData size mismatch from the discussion in
128+
/// GitHub issue #9490: when some fits fail, the "<name>_gen" columns merged
129+
/// into fitParDataSet must stay aligned with the successful fits, while
130+
/// genParDataSet keeps the entries of all generated toys.
131+
TEST(RooMCStudy, FailedFitsMergeConsistency)
132+
{
133+
RooRandom::randomGenerator()->SetSeed(4357);
134+
RooHelpers::LocalChangeMsgLevel chmsglvl{RooFit::WARNING};
135+
136+
RooWorkspace ws;
137+
ws.factory("x[-10, 10]");
138+
ws.factory("Gaussian::gauss(x, m[0, -1, 1], s[7, 5, 10])");
139+
// With only 0.7 expected events, some toys have zero events and their fits fail
140+
ws.factory("ExtendPdf::model(gauss, nev[0.7])");
141+
ws.factory("Gaussian::constraint(s, cm[7], cs[0.5])");
142+
RooArgSet extCons{*ws.pdf("constraint")};
143+
144+
const int nToys = 30;
145+
RooMCStudy mcstudy{*ws.pdf("model"),
146+
*ws.var("x"),
147+
RooFit::ExternalConstraints(extCons),
148+
RooFit::Extended(),
149+
RooFit::Silence(),
150+
RooFit::FitOptions(RooFit::PrintLevel(-1))};
151+
mcstudy.generateAndFit(nToys, 0);
152+
153+
RooDataSet const *genParData = mcstudy.genParDataSet();
154+
ASSERT_NE(genParData, nullptr);
155+
EXPECT_EQ(genParData->numEntries(), nToys);
156+
157+
RooDataSet const &fitParData = mcstudy.fitParDataSet();
158+
const int nFit = fitParData.numEntries();
159+
// Make sure the test setup is meaningful: some toys must have failed
160+
ASSERT_LT(nFit, nToys);
161+
ASSERT_GT(nFit, 0);
162+
ASSERT_NE(fitParData.get()->find("s_gen"), nullptr);
163+
164+
// The merged values must be an ordered subsequence of the generated ones
165+
std::vector<double> fitGenVals = getColumn(fitParData, "s_gen");
166+
std::vector<double> allGenVals = getColumn(*genParData, "s");
167+
std::size_t iAll = 0;
168+
for (double val : fitGenVals) {
169+
while (iAll < allGenVals.size() && allGenVals[iAll] != val) {
170+
++iAll;
171+
}
172+
EXPECT_LT(iAll, allGenVals.size()) << "merged s_gen value " << val << " misaligned with generated toys";
173+
++iAll;
174+
}
175+
}

0 commit comments

Comments
 (0)