Statistical inference¶
The final step turns the merged histograms into datacards and runs limits with
Combine, via the StatInference and
inference submodules. See
FLAF → walkthrough, stage 5
for where this sits in the pipeline.
These commands run inside CMSSW/Combine, so prefix them with cmsEnv (or open one subshell):
1. Make datacards and run limits¶
One law chain takes the merged histograms all the way to the overlay limit plots. The datacard step itself consumes 1D shapes and does no rebinning of its own; the shapes this analysis feeds it are cut from the 2D DNN-vs-HME plane by a configurable preprocessing step the chain runs first — see Where the binning is decided:
law run PlotResonantLimitsTask \
--version dev \
--hists-version VERSION_OF_THE_MERGED_HISTS \
--period Run3_2022 \
--workflow local
It runs, in order:
| Task | Does |
|---|---|
PreprocessShapesTask |
runs the configuration's preprocess: step, if it declares one |
CreateDatacardsTask |
builds the datacards from the resulting shapes |
ResonantLimitsTask |
runs combine and combines the per-era cards per mass point |
PlotResonantLimitsTask |
draws the plots declared in the configuration's limit_plots |
Each limit_plots entry becomes one overlay of its datacard globs. An entry that also
sets bands: true gets, in addition, the standard single-curve plot with the ±1σ/±2σ
bands for each of its curves — the overlay draws expected lines only. Note the dhi task
of almost the same name below (PlotResonantLimits, no Task): that is the one this
task shells out to for the band plots.
--version names what the chain writes; --hists-version names the Hists_merged
tree it reads, so a re-binning or a re-fit does not require the input histograms to be
reproduced under a new name.
The analysis configuration is config/Datacards/x_hh_bbww_DL_run3.yaml,
selected by StatInference.config in config/global.yaml. It declares the eras,
channels, categories, mass points, processes and uncertainties — the chain reads them
from there, not from global.yaml's own variable lists.
--period does not choose the era
Which eras get datacards and limits comes from the configuration's eras: and
era_groups: blocks. A real era listed inside an era_groups: entry is covered by
that meta-era and is not built standalone, so with the Run 3 configuration the chain
always builds the Run3_Early combination of all four eras regardless of --period.
--period is only used to construct a valid FLAF Setup.
Where the shape uncertainties come from¶
A type: shape entry in the configuration's uncertainties: list is only a declaration
— it names a histogram the merger must already have written. What actually produces that
histogram is the corresponding entry in config/Run3_<era>/weights.yaml, whose name:
field is the datacard nuisance name plus a _{} placeholder for Up/Down. Adding a
nuisance to the datacard configuration without adding it there fails with
Cannot find histogram ...; adding it to weights.yaml without an expression: is worse,
because the variation is then produced but is byte-identical to the nominal shape, giving a
nuisance that constrains nothing and looks fine.
A shape: entry costs more than a norm: one, and in a way that is easy to get wrong. A
norm: entry is a weight expression evaluated on the central tree, so registering one and
re-running the histTuple step is enough. A shape: entry names a shifted tree, and
HistTupleProducer opens that tree in the anaTuple and in every AnalysisCache it reads
(DNN, DeepHME). A source can therefore be present in the anaTuples and still fail, because
the caches were produced before it was registered — the symptom is
ERROR: tree Events__<source>__Up not found in file ... pointing at a luigi-tmp cache
file, not at the anaTuple. Checking the anaTuple alone is not sufficient. Adding a new
shape: source means regenerating the caches, so it belongs with an anaTuple production
rather than a weights-only re-run.
Correlating and decorrelating across eras¶
There is no correlated: switch. Combine correlates two entries when they carry the
same nuisance name and treats them as independent when the names differ, so the whole
correlation model is expressed by naming — and, because the nuisance name is the
histogram name, it is weights.yaml that decides it, not the datacard configuration.
A source decorrelated per era carries the era in its producer name, so the four eras write four different histograms:
# config/Run3_2022/weights.yaml config/Run3_2023/weights.yaml
name: CMS_scale_j_2022_{} name: CMS_scale_j_2023_{}
and the datacard configuration then declares one entry per era, each scoped with eras::
A correlated source keeps one name in every era's weights.yaml (CMS_btag_LF_{}) and
one entry in the datacard configuration with no eras: field at all — an empty era
list matches everything, which is what makes it apply to all four.
Two consequences worth knowing:
- Renaming a source to decorrelate it changes a histogram name, so it costs a HistMerger re-run. It does not need a new anaTuple production: the shifted trees are unchanged.
- The two files must change together. Splitting
weights.yamlwithout splitting the datacard configuration leaves the card asking for a histogram nobody writes any more (Cannot find histogram ... CMS_scale_j_Up), and it fails at datacard time rather than at merge time. Cross-check every card'stype: shapeentries against the producers of each era it declares before merging a correlation change.
For a meta-era such as Run3_Early, a nuisance scoped to one sub-era still applies: it
varies that sub-era's contribution to the summed shape while the others contribute their
nominal. The same holds for an lnN whose value differs per sub-era — lumi_1_13p6TeV is
written into the card as a shape for exactly that reason, since one log-normal on the
summed yield cannot express a factor that changes between eras. An lnN that does not
depend on the era stays an lnN.
b-tagging shape calibration¶
The BTV shape calibration contributes eight nuisances — CMS_btag_LF, CMS_btag_HF,
CMS_btag_{lf,hf}stats{1,2} and CMS_btag_cferr{1,2} — registered under norm: in each
era's weights.yaml. Two things about them are deliberate:
Boosted events are excluded at weight level. GetWeight in Analysis/hh_bbww.py folds
weight_bTagShape_Central into the resolved branch only of its boosted ? ... : ...
expression; boosted events carry weight_FatJetSF_Central instead. The variation
expressions therefore read (boosted ? 1.0f : weight_bTagShape_<src>{scale}_rel) *
final_weight, so a boosted event's varied weight equals its nominal weight and
DatacardMaker's canIgnore threshold drops the nuisance from boosted categories on its
own. Do not reach for unc_to_not_consider_boosted for this — that mechanism is commented
out in FLAF/Analysis/HistMergerFromHists.py and is live only in the offline
ShapeOrLogNormal.py.
The four *stats* sources are decorrelated per era, the other four are not. That
follows the BTV prescription: LF, HF and the two cferr sources describe a common
calibration and stay correlated; the {lf,hf}stats{1,2} sources are statistical and get
one nuisance per era.
How the split is expressed is worth understanding, because it is not a datacard-only
change. The nuisance name is also the histogram name — DatacardMaker reads
<process>_<name>_<Up|Down> — so an era-specific nuisance has to be named by the
producer. Each era's weights.yaml writes name: CMS_btag_lfstats1_2022_{} and the
datacard declares one entry per era scoped with eras:. A source that stays correlated
keeps a single unsuffixed name in all four files. There is no separate "decorrelate" switch:
whether a source is split is visible from what the merger writes.
Two consequences to keep in mind. Renaming here is a merge-stage change only —
HistTupleProducer keys off the weights.yaml keys (bTagShape_lfstats1, JER), and
name: is read in exactly one place, FLAF/Analysis/HistMergerFromHists.py, so
re-producing the histograms does not mean re-producing the shifted trees. And the two
halves cannot drift silently: suffix the producer without the datacard, or the reverse,
and the build stops with Cannot find histogram ....
On the dc_make side this needs two things, both of which treat a real era as a
one-element meta-era so configurations without era_groups: are untouched.
DatacardMaker.uncAppliesTo registers the nuisance on the meta-era bin when any
sub-era matches — Uncertainty.appliesTo compares against the meta-era name and would
otherwise drop the entry silently, which is why the CMS_pileup_<era> block in the
datacard configuration used to be commented out. getCombinedShape then varies only the
matching sub-eras and takes the rest at nominal, which is what the lnN path has always
done in _getSubEraLnNVariedShapes.
lnN uncertainties needed neither change and can be split with no producer involvement at
all: lumi_13p6TeV is two entries, eras: [Run3_2022, Run3_2022EE] and
eras: [Run3_2023, Run3_2023BPix], because the luminosity calibration is a per-year
measurement rather than a per-era one.
Note also that config/Run3_2024/weights.yaml has no btag entries on purpose:
config/Run3_2024/global.yaml overrides btag to HistTuple: none, so the
weight_bTagShape_*_rel columns do not exist for that era.
AK8 (fatbjet) calibration¶
Corrections/fatjet.py supplies the mirror image for boosted events: three sources
Hbb, Hcc and tau21 (FatJetCorrProducer.fatjet_Sources), registered as
CMS_bbww_ak8_{Hbb,Hcc,tau21}. Because GetWeight puts weight_FatJetSF_Central in the
boosted branch, the guard runs the other way — (boosted ?
weight_FatJetSF_<src>{scale}_rel : 1.0f) * final_weight — so it is the resolved events
that are neutralised, and canIgnore drops these nuisances from resolved categories.
Two differences from the btag block are worth knowing when reading the resulting nuisances rather than fixing them:
- There is no renormalisation step. btagShape has
UpdateBtagWeightrestoring the per-(channel, nJet) yield; the AK8 SFs have no equivalent and need none, so these nuisances legitimately carry a normalisation component. Hbbapplies only tohadronFlavour == 5andHcconly to== 4(FatJetCorrProvider::sourceApplies,Corrections/fatjet.h).Hccis therefore tiny for most processes and will fall under thecanIgnorethreshold in many categories.
The calibration files are per-era and cover the four 2022/2023 eras only, so as with btag
there is nothing to register for Run3_2024. They are decorrelated per era for the same
reason the btag *stats* sources are: four separate files means four independent fits.
Where the binning is decided¶
By a preprocessing step the datacard configuration declares, not by anything in the
chain's own logic. PreprocessShapesTask runs whatever preprocess: names, supplying
--input, --output, --era and --config; it knows nothing about what the step does.
A configuration with no preprocess: block skips the task entirely and the datacards are
built from the merged histograms unchanged, so an analysis that needs no preprocessing is
unaffected.
This analysis plugs in StatInference/bin_opt_2d/rebin_2d.py, which derives the DNN slice
boundaries and the HME mass-bin edges from the shapes themselves. Each base category
SR/res2b becomes the datacard bins SR/res2b_dnn0…dnn3.
preprocess:
script: StatInference/bin_opt_2d/rebin_2d.py
args:
- --binning-config
- config/Datacards/binning_2d.yaml
The two halves live in different places on purpose. The knobs — slice count, bin
budget, and the minimum signal/background yields and effective-entry floors a bin must
satisfy — are analysis configuration, versioned with the card in
config/Datacards/binning_2d.yaml.
The derived binning.json is a product, not configuration, and is written into the
task's output on EOS beside the shapes it produced.
Every era in eras: is binned on its own statistics and gets its own limit; a group era
from era_groups: is binned on its members' summed statistics, and its output keeps the
sub-eras separate (Run3_Early/{Run3_2022,Run3_2022EE,…}) so the per-era uncertainties
survive to be combined in the datacard maker.
rebin_2d.py is a plain executable, so a binning can be inspected without going through
the chain by calling it with the same four arguments the task passes:
python3 StatInference/bin_opt_2d/rebin_2d.py \
--input "$ANALYSIS_BIG_DATA_PATH/VERSION_OF_THE_MERGED_HISTS/Hists_merged" \
--output /tmp/$USER/rebin_Run3_Early \
--era Run3_Early \
--config config/Datacards/x_hh_bbww_DL_run3.yaml \
--binning-config config/Datacards/binning_2d.yaml
The datacard configuration then lists the sliced names in categories: and repeats the
category_pattern used to write them, which is how the per-category limits group the
slices of one base category back together.
There is also StatInference/bin_opt/, an offline combine-driven search over candidate
binnings feeding the hist_bins option. This analysis does not use it, and leaves
hist_bins unset.
Datacards on their own¶
To build cards outside the chain — a quick check on shapes that are already rebinned:
cmsEnv python3 StatInference/dc_make/create_datacards.py \
--input PATH_TO_REBINNED_SHAPES \
--output PATH_TO_CARDS \
--config config/Datacards/x_hh_bbww_DL_run3.yaml
2. Run limits on existing datacards¶
For cards you already have on disk, the dhi task can be called directly:
Hints:
- add
--workflow htcondorto submit to the batch system (local by default); - add
--remove-output 4,a,yto clear previous outputs; - add
--print-status 0to get the workflow status and the output file name; - options and background: the cms-hh inference documentation.
3. Pulls & impacts¶
Declared in the datacard configuration's impact_plots block and drawn by
PlotPullsAndImpactsTask, which writes to <version>/ImpactPlots/<mass>/ on fs_default
beside the limit plots:
law run PlotPullsAndImpactsTask \
--version dev \
--hists-version VERSION_OF_THE_MERGED_HISTS \
--period Run3_2022
impact_plots:
- name: combined
masses: [ 300, 600, 800 ]
poi_value: limit
plot_params:
method: default
order_by_impact: true
mc_stats: false
poi_value is the signal strength the Asimov dataset is built at, and it has to be near
the limit to mean anything. dhi's default is r=1, which on this scan is an almost
invisible signal at 300 (limit ~6.8) and ten times more than can be excluded at 800
(limit ~0.065) — the first gives a background-only ranking, the second pins r and
collapses every impact to ~0. limit reads the expected limit for each mass from the
limits already merged for those cards; a number sets it directly.
Changing poi_value does not move the outputs
It reaches combine as --expectSignal through PullsAndImpacts' custom_args, which
is significant for task identity but not part of the store path — every value
writes to the same file. Clear PullsAndImpacts, MergePullsAndImpacts and
PlotPullsAndImpacts for that version under inference/data/store before re-running
with a different one, or law reports the old fit as complete and you get the previous
ranking. It cannot go through dhi's parameter_values either: with hh_model=NO_STR,
POITask hard-codes both the joined values and the postfix, dropping it silently.
Each entry becomes one dhi PlotPullsAndImpacts per era and mass, and the plots land in
<version>/ImpactPlots/<name>/<era>/<mass>/.
With no glob:, an entry uses the combined card ResonantLimitsTask writes at
data/<version>/Datacards/combined/combined_<mass>.txt — every era and category in one
fit, which is the ranking that describes the result. It is drawn once and labelled
combined.
To rank a single category instead, give the entry a glob:, resolved against that era's
datacard directory with ${ERA} substituted, the same idiom limit_plots uses:
The glob and the mass must select exactly one card: PullsAndImpacts fits a single
workspace, one combine job per parameter, so it cannot be handed a set. If it matches none
or several, the error names the directory and lists what is in it. Note dhi draws one plot
per card — it cannot split a card into per-category panels — so per-category rankings come
from pointing at the per-category cards, which the chain already writes.
plot_params accepts any PlotPullsAndImpacts parameter and is checked against them, so
a typo is refused rather than ignored. hh_model is pinned to NO_STR: this is a
resonant search, and the dhi default would otherwise fit r alongside kl, kt, CV
and C2V, against a workspace other than the one the chain built.
What a mass costs depends on method
With method: robust — what this analysis uses — dhi runs one fit per mass and
reads every impact off the inverted Hessian, so all three masses take minutes.
method: default instead fits each nuisance separately, roughly two combine jobs per
nuisance per mass (~160 here), and only that case is worth sending to a batch system.
create_branch_map in dhi/tasks/pulls_impacts.py is where the difference lives.
Pass --redraw to redraw without refitting.
method: robust can silently drop a nuisance
robustHesse removes parameters it cannot invert, logging Dropping <name> from the
hessian and then exiting successfully. The dropped nuisance is simply absent from
the plot and the merged JSON, with nothing marking its absence — on the Run3_Early
cards this happens to CMS_res_j. The task diffs the fitted parameters against the
card's own nuisance lines afterwards and warns, naming what went missing; take that
warning seriously before reading a ranking as complete.
mc_stats needs parameters_per_page
The combined cards carry a few hundred autoMCStats bins, so mc_stats: true puts
~460 parameters on the plot. parameters_per_page defaults to -1, meaning a single
page, and the result is an unreadable hairline strip rather than an error — so the
task refuses the combination. Set parameters_per_page: 25 alongside it.