Skip to content

Add sl dl combination - #26

Open
aebid wants to merge 13 commits into
cms-flaf:mainfrom
aebid:Add_SL_DL_Combination
Open

aebid wants to merge 13 commits into
cms-flaf:mainfrom
aebid:Add_SL_DL_Combination

Conversation

@aebid

@aebid aebid commented Sep 26, 2026

Copy link
Copy Markdown

This PR is based on #24 and #25

The combination of SL and DL requires the previous PRs to finish first, and then this adds support of a two-configuration mixing in the plotting wrappers and limit setting

aebid and others added 13 commits September 26, 2026 00:13
The HH->bbWW single-lepton shapes hold, in the file for mass MX, a signal region for
every mass (SR_SL_M300 ... SR_SL_M1000); only SR_SL_M${MX} belongs to the MX limit.
Listing those directories as categories puts all ten into every card as separate bins,
and nothing reports it, since every file has all of them.

A configuration can now map a category to its input directory, with the model
parameters substituted at read time:

    input_categories:
      SR_SL/res2b: SR_SL_M${MX}/res2b

The category keeps one name for every mass -- bin names, per-category card
directories, Process/Uncertainty scoping -- and only the read is redirected, in the
maker's four histogram-path builders. A map entry for a category that is not
configured is an error; configurations without a map read exactly as before.

This covers cards that read the merged shapes directly. rebin_2d.py does not apply the
map, so a card with a `preprocess:` step still needs its input under the category name.

Checked on the real SL shapes (v2605a_TwoStageDNN_Shapes_v3, MX=500, Run3_Early, no
preprocessing): the card has the six bins {e,mu} x SR_SL/{res2b,boosted,recovery}, and
TT and signal in every bin equal the sum of the four sub-eras' SR_SL_M500 inputs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ositivity

Two changes to what a candidate binning has to satisfy, both about backgrounds that are
present but not measured.

min_bin_bkg_each_neff requires every non-negligible background to have at least this many
effective MC entries in every bin. min_bin_bkg_neff already existed but constrains only
the *summed* background, so it is satisfied by any one well-measured process -- TT alone
was covering for DY at N_eff 0.1. The sliced axis has carried the per-process arm for a
while (min_slice_bkg_each_neff); the mass axis, where essentially all fit bins live, had
no per-process test at all. Failing bins are merged into their lower-signal neighbour, so
the resolution given up is where the signal is thinnest. Defaults to 0.0, which is the
previous behaviour exactly.

Positivity is no longer exemptable, in either _bin_passes or _slice_passes. `exempt`
exists so that a negligible process cannot veto every candidate split -- it is about the
*magnitude* floor, not about sign. Letting an exempt process go negative is not merely
an ugly bin: resolveNegativeBins in dc_make/maker.py rejects a negative bin holding
>= signalFractionForRelevantBins of the signal unconditionally, ignoring
allow_negative_bins_within_error, so the datacard fails to build at all. Observed at
m1000 muMu/SR/recovery_dnn1, where ST was 4.24% of the slice -- just under min_bkg_frac
0.05, hence exempt -- and came out at -0.567 +- 0.789, killing the production.

Measured on HH->bbWW Run3_Early: with n_slices 4 and bkg_per_bin 1.0, turning the new
gate on at 2.0 gives a geometric-mean expected limit 0.988x the production binning --
better on the limit *and* on every validity metric at once. Per-process bins below N_eff
1 fall 1.5% -> 0.2%, below N_eff 2 fall 3.2% -> 1.1%, the median DY N_eff nearly doubles
16.3 -> 31.8, and DY bins below N_eff 4 fall 29.2% -> 18.2%, for 3.7% of the fit bins
(6220 -> 5989).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We5E9ZkM9tanReeRE2WSSR
Everything that decides *where* a boundary goes -- slice finding and the gates it
applies, which processes are minor enough to exempt, the figures of merit, and the
histogram plumbing -- moves out of bin_opt_2d/rebin_2d.py into common/binning_core.py,
unchanged. The cell tables the HME-box search is built on need the same answers to the
same questions, and importing them from one place is what keeps a second copy from
drifting.

What stays behind in rebin_2d.py is the part that knows what a "bin" is, plus
BINNING_DEFAULTS and load_binning_config(), whose "unknown key raises" contract only
works if the file that reads the knobs states its own valid keys.

No behaviour change: the moved code is byte-identical to the lines it came from, and
the only other change to rebin_2d.py is its import header. Carved out of e205797 on
Binning_2D_DP, without the 1D binner that commit also added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…olved ones

Once a category list is expanded against a rebinning record (the base "SR/res2b" becomes
"SR/res2b_hmebox0"), the map's keys -- the names the configuration writes -- no longer
appear in self.categories, and every key read as unknown. Main does not expand categories
yet, so nothing is affected here; the HME-box binning PR adds the expansion.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
strategy: hme_box cuts one contiguous HME window per category and bins the DNN inside
it, instead of cutting the DNN into slices and binning HME inside each. Everything
outside the box is dropped, so the category becomes a single datacard bin
(SR/res2b -> SR/res2b_hmebox0). Box and DNN binning are chosen together: a window is
scored by the best DNN binning it admits (_box_value), not by its own integrated
significance, which would only widen it to swallow signal and ignore the shape the fit
uses. The DNN binning inside a box is an exact partition search (partition_dp) against
the same Asimov Z^2 the greedy slice boundaries use, with the bin gates as a hard mask;
_best_count() takes the best-scoring count up to max_bins_per_slice, and
dp_min_bin_gain gives back any bin whose split raised Z^2 by less than that fraction of
the category's total. The windows are scanned on a box_stride grid inside the
box_signal_quantile range of the signal, then refined within +-box_refine.

min_bkg_frac is judged once over the whole box, and the gates are those of
_bin_passes, including min_bin_bkg_each_neff and the never-exempt positivity.

common/binning_dp.py holds the prefix-sum cells that make this affordable (every window
and candidate bin is an O(1) lookup instead of a ROOT integral), the vectorised box
tables, and check_box_mask(), which re-derives a sample of the tables with the
canonical _bin_passes on every run so the two spellings of the gate cannot drift.

binning.json records the box layout as "y_range"/"x_ranges" beside the DNN-slice
layout's "x_range"/"y_ranges", and replays either; both strategies now also record the
objective (Z, Z^2 per slice, bin count) recomputed from the written ranges.
CreateDatacardsTask labels the shape plots by the surviving axis read from that record,
so box panels are labelled by the DNN rather than by HME.

Default stays "greedy", which is unchanged by this commit.

Carved out of Binning_2D_DP (92e0010 and the parts of 39f9511, 88f612d, 14287b5 it
uses), without the "dp" strategy, the budget allocator, axis alternation,
binning_exclude_processes, fill_negative_bins, fitted tails, per-process N_eff floors,
exempt_scope, the dc_make base-category expansion and the plot book. Verified identical
to Binning_2D_DP on v2605a_DNNOutputs_v3 Run3_Early, MX 500 and 1000: binning.json
equal apart from the dropped knobs (all at their defaults there), and every one of
188928 hme_box and 755712 greedy rebinned histograms equal in edges, contents and
errors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The helpers moved to common/binning_core.py were moved to share them with a 1D binner
that is not part of this change, so they go back where they were on main, unchanged
except _slice_passes, which keeps the positivity rule. binning_dp.py no longer imports
from the binner: the one figure of merit it needs is passed to build_cells, as the gate
already is passed to check_window_mask. The development-time checks (parity_check, the
partition self-test) and the Cells methods only they used are dropped; check_window_mask
still re-derives a sample of every window's tables against _bin_passes and
significance() on each run.

The method is not specific to HH->bbWW's HME: it cuts one window on y and bins x inside
it, whatever the two axes hold. So the strategy is y_window (was hme_box), its knobs are
window_stride, window_refine and window_signal_quantile, and the prose describes x and y
with HH->bbWW as the example. greedy stays the default.

Checked against Binning_2D_DP on Run3_Early, v2605a_DNNOutputs_v3, MX 500 and 1000: the
rebinned shapes are identical for y_window (188928 histograms) and greedy (755712), and
binning.json is identical outside the knobs block, the reference's old names mapped to
the new.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A window on y with x binned inside it is one orientation of a general method, so the
strategy is now "window", and window_axis (y, the default, or x) says which axis the
window is cut on; the other is the one binned inside it. --window-axis sets it from the
command line, and any other value is refused.

The search is written with the window on y. For window_axis x, build_cells builds the
cells transposed, the same search runs, and the result is returned in the input's axes:
a selection on x with bins along y, the layout the greedy slices already use, so the
writer, binning.json, replay and plot labels need no change.

Checked on Run3_Early, v2605a_DNNOutputs_v3, against Binning_2D_DP (the reference the
method was measured on): window with window_axis y reproduces its rebinned shapes exactly
at MX 500 and 1000 (188928 histograms), and greedy does too (755712). For window_axis x,
every 2D input at MX 500 was transposed and rebinned with window_axis x: the binning is
the same as window_axis y on the original input, and the 94464 shapes agree, 66464
bit-identical and the rest within 1e-9 relative (the transposed sums round differently).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comments and docstrings only: the code is unchanged (the syntax tree without docstrings
is identical to the previous commit's).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rebin_2d.py reads each base category from where input_categories places it for the
file's mass (e.g. SR/res2b from SR_SL_M${MX}/res2b) and writes it under the category's
own name, so binning.json and the rebinned shapes are keyed the same way for every mass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A card that reads rebinned shapes lists the written names (SR/res2b_hmebox0) while its
input_categories map is keyed by the base category (SR/res2b) that rebin_2d.py reads.
The maker now accepts a key that is a configured category or the base of one, and still
refuses any other, which catches typos. Sliced names are read under their own names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The merged input for one mass carries every signal sample at every mass, but a rebinned
file is only read for its own mass, since input_file_pattern names it. Other points'
signal histograms, nominal and systematic, are now skipped, matched from the
configuration's signal name patterns; backgrounds and data are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… limit

Configurations built from different inputs (the single- and double-lepton channels)
cannot be summed histogram by histogram like a group era's sub-eras; they are combined
card by card, in one law invocation:

- datacard_config / datacard_tag on StatInferenceTask: a task can run a named
  configuration, with its products under <version>/<tag>/. An empty tag keeps every
  path as before.
- A combination configuration (`members: {name: {config, hists_version, user_custom}}`).
  member_req() runs each member's chain under its own tag and histograms, and
  CombinedResonantLimitsTask combines their headline cards per mass, labelled by member,
  after checking they share the mass points and no bin.
- A limit_plots curve may be a list of globs; in a combination each names its member,
  "SL:${ERA}/e/*.txt", and each member's own limit_plots are drawn as well.

ResonantLimitsTask's per-mass combineCards and publish steps are factored out to be
shared. Impacts on a combination are refused for now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@aebid

aebid commented Sep 26, 2026

Copy link
Copy Markdown
Author

@cms-flaf-bot

Copy link
Copy Markdown

pipeline#15865813 started

@cms-flaf-bot

Copy link
Copy Markdown

pipeline#15865813 failed

@kandrosov

Copy link
Copy Markdown

Heads-up: #32 (multi-era change set C) changes the law tasks this PR edits (StatInferenceTask, MergedHists, CreateDatacardsTask, PreprocessShapesTask, ResonantLimitsTask, the plot tasks). It is planned to merge before this PR, so this PR will need adapting once it does. What changes:

  • Every StatInference task takes several eras (allow_multi_era = True). --era must hold every real era behind the cards of the datacard configuration; otherwise the run stops, naming the missing eras. self.era[0], self.global_params and an era= keyword in req() are no longer valid there:
    • an era's values come from self.setup_for(era);
    • run-wide values come from self.common(getter, what), or self.statinference_settings() for the StatInference block, which must agree between the eras.
  • CreateDatacardsTask and PreprocessShapesTask are one workflow each, with one branch per card (eras:, in order). The card era is self.branch_data, and its real eras are self.get_sub_eras(card). meta_era is gone. Branch i of one requires branch i of the other.
  • Paths: outputs keep Datacards/<card> and Hists_preprocessed/<card>. The job state and the logs are keyed on version and class (store_parts(), remote_log_dir_target()), never on --era.
  • MergedHists takes hist_era and reads that era's own fs_HistTuple.
  • ResonantLimitsTask requires the one card workflow and stages each card through era_card_outputs().
  • Construction-time check: ResonantLimitsTask, ResonantLimitsAndHistPlotTask and the plot tasks check the eras when they are constructed (check_card_eras). A new task a run starts from (such as CombinedResonantLimitsTask) would set it too.

For the SL+DL combination this suggests one card workflow per member configuration. Each member's identity would go into store_parts() (two configurations under one --version would otherwise share a job file, since it is named after the branch numbers), and the card rule would apply per member.

test/test_law_chain.py and test/test_card_eras.py (run by the new unit-tests workflow) describe what the chain must keep, and are a good check for the rebased version. FLAF's rename checker, now pinned at 4d9602c in this repository's CI, rejects self.era[...] and era= in req().

This PR already conflicts with main since the period → era rename (#27), so a rebase is needed either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants