Implement integrated CMS Combine backend - #520
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces an object-oriented Python interface for defining Combine fits, adds a new fccanalysis fit subcommand to generate datacards (and optionally execute Combine), and wires an integration test into CTest to validate end-to-end behavior.
Changes:
- Added
python/combine.pyfor loading userFitscripts, validating configs, and writing Combine text datacards. - Added
python/fit.pyand CLI plumbing (python/parsers.py,bin/fccanalysis) for the newfitsubcommand with optional backend execution. - Added an example OOP fit configuration and a CTest-driven Python integration test.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/python/test_combine_backend.py | Adds a Python integration test that runs fccanalysis fit and validates datacard generation / execution behavior. |
| tests/CMakeLists.txt | Registers the new Python integration test with CTest. |
| python/parsers.py | Adds a new fit subcommand parser and arguments. |
| python/fit.py | Implements the fccanalysis fit subcommand entry point and Combine execution wrapper. |
| python/combine.py | Implements Fit script loading, config sanitization/validation, and datacard writing. |
| examples/fcc_ee_zh_mumu_bb.py | Provides an example Fit class config for the new backend. |
| bin/fccanalysis | Wires the new fit command into the main CLI dispatcher. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for ch_name, ch_info in channels.items(): | ||
| # Determine what the shape mapping rule is for this channel | ||
| # Handles wildcard '*' or specific process rules | ||
| mapping_rule = shapes_config.get('*', {}).get(ch_name) or shapes_config.get(proc_name, {}).get(ch_name) | ||
|
|
||
| if not mapping_rule: | ||
| continue | ||
|
|
||
| # Extract the ROOT file path (assumes space-separated path and inner structure mapping) | ||
| root_file_path = mapping_rule.split()[0] | ||
|
|
||
| if not os.path.isfile(root_file_path): | ||
| LOGGER.warning("Shape file '%s' not created yet or using relative build path. Skipping deep object check.", root_file_path) | ||
| continue | ||
|
|
||
| # Open the file via PyROOT to peek inside | ||
| if root_file_path in failed_to_open_files: | ||
| raise ValueError(f"Validation Error: Failed to open shape ROOT file: {root_file_path}") | ||
|
|
||
| for proc_name in ch_info.get('processes', {}).keys(): | ||
| if proc_name in apply_to and str(apply_to[proc_name]) != '-': | ||
|
|
||
| # Resolve the inner path template (e.g., $CHANNEL/$PROCESS -> mumu_bjets_channel/ZH_signal) | ||
| base_path = mapping_rule.split()[1].replace('$CHANNEL', ch_name).replace('$PROCESS', proc_name) | ||
|
|
| ) | ||
|
|
||
| # Ensure test data assets are fully downloaded via CI before running this test | ||
| set_tests_properties(test_combine_fit_backend PROPERTIES DEPENDS get_test_inputs) |
| test_output_dir = os.path.join(REPO_ROOT, "outputs", "test_integration", "mumu") | ||
| test_datacard = os.path.join(test_output_dir, "datacard.txt") |
| except KeyboardInterrupt: | ||
| LOGGER.info('Fit execution interrupted by user (Ctrl+C). Terminating cleanly...') | ||
| sys.exit(0) |
| for syst_name, syst_info in self.datacard.systematics.items(): | ||
| syst_type = syst_info.get("type", "lnN") | ||
| apply_to = syst_info.get("apply_to", {}) | ||
|
|
||
| # Start each row with the systematic name and its type | ||
| row_cells = [syst_name, syst_type] | ||
|
|
||
| # Loop over channels | ||
| for ch_name, ch_info in self.datacard.channels.items(): |
|
Hi @captainvogon, looks good. One thing to think about is how possible next step (plotting) will interact with the combine products. In the current state of the framework, the following step always require input directory, which is produced as output of the previous step. |
Shape Systematic proc_name Bug (part 1) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (4)
python/combine.py:28
jmaxis computed from the first channel only, which can produce an incorrect header when different channels have different numbers of background processes. This can lead to invalid/inconsistent Combine datacards.
def _get_metadata(self) -> Dict[str, int]:
imax = len(self.channels)
jmax = 0
if self.channels:
first_channel = list(self.channels.values())[0]
python/fit.py:28
- The CLI allows
--backend pyhf, butrun_fit()only handlescombine. Forpyhf, the command exits successfully without generating output or reporting an error.
if backend == 'combine':
from combine import generate_datacard
generate_datacard(anapath, output_path)
python/combine.py:234
- The shape-systematics validation block references
proc_namebefore it is defined when selectingmapping_rule, which raisesNameErrorfor anyshapesystematic. Additionally, the validation currently assumes variations are named by appending_{syst}{Up/Down}to the nominal template, ignoring the$SYSTEMATICtemplate token when provided.
# Extract the ROOT file path (assumes space-separated path and inner structure mapping)
root_file_path = mapping_rule.split()[0]
if not os.path.isfile(root_file_path):
tests/CMakeLists.txt:36
set_tests_properties(... DEPENDS get_test_inputs)references a CTest test namedget_test_inputs, but input downloading is performed at CMake configure time (top-levelCMakeLists.txt) and there is no such CTest test. This dependency will be ignored and may emit CTest warnings.
# Ensure test data assets are fully downloaded via CI before running this test
set_tests_properties(test_combine_fit_backend PROPERTIES DEPENDS get_test_inputs)
endif()
| b_path = m_rule.split()[1].replace('$CHANNEL', c_name).replace('$PROCESS', p_name) | ||
| for var in ['Up', 'Down']: | ||
| t_path = f"{b_path}_{s_name}{var}" | ||
| if rf_path not in targets_to_load: | ||
| targets_to_load[rf_path] = set() | ||
| targets_to_load[rf_path].add(t_path) |
…, and metadata consistency
…le CLI arg overrides
|
|
||
| # 3. Add the --out flag | ||
| output_dir = os.path.dirname(os.path.abspath(output_path)) | ||
| full_command.extend(['--out', output_dir]) |
There was a problem hiding this comment.
if user provides --out as well on the command line, who's --out wins?
Overview
This PR transitions the Combine statistical backend integration from a standalone YAML parser to a fully integrated, object-oriented Python framework.
Key Changes
Fitclass (seeexamples/fcc_ee_zh_mumu_bb.py). Users now define channels, processes, and systematics natively in Python.python/combine.py): Built the wrapper to dynamically load the user's fit script, instantiate theFitclass, and validate inputs.Fitobject into a production-ready Combinedatacard.txt.test_combine_backend.py) that verify config sanitization, datacard generation, and isolatedcombinetool execution while gracefully handling CI environment limits.Status
All framework integration tests and CI pipelines are passing successfully.