From 77756047587190c1719b118c66b73efd1326f568 Mon Sep 17 00:00:00 2001 From: juvanden Date: Mon, 23 Feb 2026 15:25:58 +0100 Subject: [PATCH 1/5] all changes to have multiple shifts in CreateHistograms all the way to plotting and datacards --- columnflow/histogramming/shifts.py | 270 ++++++++++++++++++++++++ columnflow/inference/__init__.py | 2 + columnflow/tasks/cms/inference.py | 4 + columnflow/tasks/framework/inference.py | 61 +++++- columnflow/tasks/histograms.py | 174 ++++++++------- columnflow/tasks/plotting.py | 27 ++- 6 files changed, 439 insertions(+), 99 deletions(-) create mode 100644 columnflow/histogramming/shifts.py diff --git a/columnflow/histogramming/shifts.py b/columnflow/histogramming/shifts.py new file mode 100644 index 000000000..887c91a53 --- /dev/null +++ b/columnflow/histogramming/shifts.py @@ -0,0 +1,270 @@ +# coding: utf-8 + +""" +Default histogram producers that define columnflow's default behavior. +""" + +from __future__ import annotations + +import law +import order as od + +from columnflow.histogramming import HistProducer, hist_producer +from columnflow.util import maybe_import +from columnflow.hist_util import create_hist_from_variables, fill_hist, translate_hist_intcat_to_strcat +from columnflow.columnar_util import has_ak_column, Route, optional_column as optional +from columnflow.types import Any + +np = maybe_import("numpy") +ak = maybe_import("awkward") +hist = maybe_import("hist") + + +@hist_producer() +def shifts_hist(self: HistProducer, events: ak.Array, **kwargs) -> ak.Array: + """ + WeightProducer that combines all event weights from the *event_weights* aux entry from either + the config or the dataset. The weights are multiplied together to form the full event weight. + + The expected structure of the *event_weights* aux entry is a dictionary with the weight column + name as key and a list of shift sources as values. The shift sources are used to declare the + shifts that the produced event weight depends on. Example: + + .. code-block:: python + + from columnflow.config_util import get_shifts_from_sources + # add weights and their corresponding shifts for all datasets + cfg.x.event_weights = { + "normalization_weight": [], + "muon_weight": get_shifts_from_sources(config, "mu_sf"), + "btag_weight": get_shifts_from_sources(config, "btag_hf", "btag_lf"), + } + for dataset_inst in cfg.datasets: + # add dataset-specific weights and their corresponding shifts + dataset.x.event_weights = {} + if not dataset_inst.has_tag("skip_pdf"): + dataset_inst.x.event_weights["pdf_weight"] = get_shifts_from_sources(config, "pdf") + """ + # build the full event weight + weight = ak.Array(np.ones(len(events))) + if self.dataset_inst.is_mc and len(events): + # multiply weights from global config `event_weights` aux entry + for column in self.config_inst.x.event_weights: + weight = weight * Route(column).apply(events) + + # multiply weights from dataset-specific `event_weights` aux entry + for column in self.dataset_inst.x("event_weights", []): + if has_ak_column(events, column): + weight = weight * Route(column).apply(events) + else: + self.logger.warning_once( + f"missing_dataset_weight_{column}", + f"weight '{column}' for dataset {self.dataset_inst.name} not found", + ) + + if self.subshift_insts: + all_weights = [] + for subshift_inst in self.subshift_insts: + subshift_weight = weight + for nominal, variation in subshift_inst.x.column_aliases.items(): + # only apply aliases when relevant to event weights + if ( + (nominal in self.dataset_inst.x("event_weights", [])) | + (nominal in self.config_inst.x("event_weights", [])) + ): + nominal_arr = Route(nominal).apply(events).to_numpy() + modifier = np.ones_like(nominal_arr) + nonzero = np.nonzero(nominal_arr) + modifier[nonzero] = Route(variation).apply(events).to_numpy()[nonzero] / nominal_arr[nonzero] + subshift_weight = subshift_weight * modifier + all_weights.append(subshift_weight) + return events, np.transpose(all_weights) + + if self.dataset_inst.is_data and len(events) and self.config_inst.x.data_event_weights: + # for data we apply a seperate set of weights if applicable + for column in self.config_inst.x.data_event_weights: + weight = weight * Route(column).apply(events) + + return events, weight + + +@shifts_hist.init +def shifts_hist_init(self: HistProducer) -> None: + if not getattr(self, "dataset_inst", None): + return + + weight_columns = set() + + # add used weight columns and declare shifts that the produced event weight depends on + if self.dataset_inst.is_mc: + if self.config_inst.has_aux("event_weights"): + weight_columns |= {Route(column) for column in self.config_inst.x.event_weights} + for shift_insts in self.config_inst.x.event_weights.values(): + self.shifts |= {shift_inst.name for shift_inst in shift_insts} + + # optionally also for weights defined by a dataset + if self.dataset_inst.has_aux("event_weights"): + weight_columns |= {Route(column) for column in self.dataset_inst.x("event_weights", [])} + for shift_insts in self.dataset_inst.x.event_weights.values(): + self.shifts |= {shift_inst.name for shift_inst in shift_insts} + else: + if self.config_inst.has_aux("data_event_weights"): + weight_columns |= {Route(column) for column in self.config_inst.x.data_event_weights} + for shift_insts in self.config_inst.x.data_event_weights.values(): + self.shifts |= {shift_inst.name for shift_inst in shift_insts} + + # add weight columns to uses + self.uses |= weight_columns + + +@shifts_hist.post_init +def shifts_hist_post_init(self: HistProducer, task: law.Task, **kwargs) -> None: + + config_inst = self.config_inst + subshifts = task.local_shift_inst.x("subshifts", []) + self.subshift_insts = set([config_inst.get_shift(s) for s in subshifts if config_inst.has_shift(s)]) + + self.uses |= set(map(optional, {c for s in self.subshift_insts for c in s.x.column_aliases.values()})) + + +@shifts_hist.create_hist +def shifts_hist_create_hist( + self: HistProducer, + variables: list[od.Variable], + task: law.Task, + **kwargs, +) -> hist.Histogram: + """ + Define the histogram structure for the default histogram producer. + """ + return create_hist_from_variables( + *variables, + categorical_axes=( + ("category", "intcat"), + ("process", "intcat"), + ("shift", "intcat"), + ), + weight=True, + ) + + +@shifts_hist.fill_hist +def shifts_hist_fill_hist(self: HistProducer, h: hist.Hist, data: dict[str, Any], task: law.Task, **kwargs) -> None: + + # make sure that nan values in data are not propagated to the histogram (removed from data instead) + variable_insts = [self.config_inst.get_variable(key) for key in list( + data.keys()) if key not in ("category", "process", "shift", "weight")] + nan_masks = ak.all([~(ak.is_none(ak.nan_to_none(data[variable_inst.name]))) + for variable_inst in variable_insts], axis=0) + + new_data = {} + for key, value in data.items(): + if (type(value) is ak.Array) | (type(value) is np.ndarray): + new_data[key] = value[nan_masks] + else: + new_data[key] = value + + # make sure that the histogram is integer if discrete_x + for variable_inst in variable_insts: + if variable_inst.discrete_x: + new_data[variable_inst.name] = ak.to_numpy(new_data[variable_inst.name]).astype(int) + + _fill = lambda d: fill_hist(h, d, last_edge_inclusive=task.last_edge_inclusive) + if self.subshift_insts: + if (new_data["shift"] == task.global_shift_inst.id): + for i, subshift_inst in enumerate(self.subshift_insts): + if subshift_inst not in self.sel_subshift_insts: + _fill(new_data | {"shift": subshift_inst.id, "weight": new_data["weight"][:, i]}) + else: + subshift_idx = [s.id for s in self.subshift_insts].index(new_data["shift"]) + _fill(new_data | {"weight": new_data["weight"][:, subshift_idx]}) + + else: + _fill(new_data) + + +@shifts_hist.post_process_hist +def shifts_hist_post_process_hist(self: HistProducer, h: hist.Histogram, task: law.Task) -> hist.Histogram: + """ + Post-process the histogram, converting integer to string axis for consistent lookup across configs where ids might + be different. + """ + axis_names = {ax.name for ax in h.axes} + + # translate axes + if "category" in axis_names: + category_map = {cat.id: cat.name for cat in self.config_inst.get_leaf_categories()} + h = translate_hist_intcat_to_strcat(h, "category", category_map) + if "process" in axis_names: + process_map = {proc_id: self.config_inst.get_process(proc_id).name for proc_id in h.axes["process"]} + h = translate_hist_intcat_to_strcat(h, "process", process_map) + if "shift" in axis_names: + shift_map = {s.id: s.name for s in self.subshift_insts or [task.global_shift_inst]} + h = translate_hist_intcat_to_strcat(h, "shift", shift_map) + + return h + + +@shifts_hist.requires +def shifts_hist_requires(self: HistProducer, task: law.Task, reqs: dict) -> None: + """ + In the case of subshift add the required producers for the selection dependent shifts + """ + + config_inst = self.config_inst + subshifts = task.local_shift_inst.x("subshifts", []) + self.subshift_insts = set([config_inst.get_shift(s) for s in subshifts if config_inst.has_shift(s)]) + self.sel_subshift_insts = {s for s in self.subshift_insts if "selection_dependent" in s.tags} + + if not self.sel_subshift_insts: + return + + reqs["producers"] = { + subshift_inst.name: [ + task.reqs.ProduceColumns.req( + task, + shift=subshift_inst.name, + producer=producer_inst.cls_name, + producer_inst=producer_inst, + ) + for producer_inst in task.producer_insts + if producer_inst.produced_columns + ] + for subshift_inst in self.sel_subshift_insts + } + + if task.ml_model_insts: + reqs["ml"] = { + subshift_inst.name: [ + task.reqs.MLEvaluation.req( + task, + shift=subshift_inst.name, + ml_model=ml_model_inst.cls_name, + ) + for ml_model_inst in task.ml_model_insts + ] + for subshift_inst in self.sel_subshift_insts + } + + +@shifts_hist.setup +def shifts_hist_setup( + self: HistProducer, + reqs: dict, + inputs: dict, + task: law.Task, + reader_targets: law.util.InsertableDict, +) -> None: + + if not self.sel_subshift_insts: + return + + for req, input in inputs.items(): + for shift, shift_input in input.items(): + for i, inp in enumerate(shift_input): + if req == "producers": + reader_targets[f"{shift}__{req}_{i}"] = inp["columns"] + else: + reader_targets[f"{shift}__{req}_{i}"] = inp["mlcolumns"] + + return diff --git a/columnflow/inference/__init__.py b/columnflow/inference/__init__.py index 0023c1352..a9a748e7a 100644 --- a/columnflow/inference/__init__.py +++ b/columnflow/inference/__init__.py @@ -500,6 +500,7 @@ def parameter_spec( config_data: dict[str, DotDict] | None = None, effect: Any | None = 1.0, effect_precision: int = 4, + is_dynamic: bool = False, ) -> DotDict: """ Returns a dictionary representing a (nuisance) parameter, forwarding all arguments. @@ -526,6 +527,7 @@ def parameter_spec( )), ("effect", effect), ("effect_precision", effect_precision), + ("is_dynamic", bool(is_dynamic)), ]) @classmethod diff --git a/columnflow/tasks/cms/inference.py b/columnflow/tasks/cms/inference.py index abf8ec2ec..71be8d773 100644 --- a/columnflow/tasks/cms/inference.py +++ b/columnflow/tasks/cms/inference.py @@ -195,6 +195,10 @@ def run(self): else None ) for d in ["up", "down"]: + shift_inst = config_inst.get_shift(f"{shift_source}_{d}") + if shift_inst.has_tag("is_collection"): + continue + if shift_source and f"{shift_source}_{d}" not in h_proc.axes["shift"]: raise ValueError( f"cannot find '{shift_source}_{d}' in shift axis of histogram for process " diff --git a/columnflow/tasks/framework/inference.py b/columnflow/tasks/framework/inference.py index 508b67e17..0a3b2d395 100644 --- a/columnflow/tasks/framework/inference.py +++ b/columnflow/tasks/framework/inference.py @@ -25,6 +25,8 @@ if TYPE_CHECKING: hist = maybe_import("hist") +logger = law.logger.get_logger(__name__) + class SerializeInferenceModelBase( CalibratorClassesMixin, @@ -100,13 +102,21 @@ def get_data_datasets(cls, config_inst: od.Config, cat_obj: DotDict) -> list[str if not config_data.data_datasets: return [] + # when datasets are defined on the process object itself, interpret them as patterns + if config_data.data_datasets: + return [ + dataset.name + for dataset in config_inst.datasets + if ( + dataset.is_data and + law.util.multi_match(dataset.name, config_data.data_datasets, mode=any) + ) + ] + + # otherwise, check the config return [ - dataset.name - for dataset in config_inst.datasets - if ( - dataset.is_data and - law.util.multi_match(dataset.name, config_data.data_datasets, mode=any) - ) + dataset_inst.name + for dataset_inst in get_datasets_from_process(config_inst, config_data.process) ] @law.workflow_property(cache=True) @@ -117,7 +127,7 @@ def combined_config_data(self) -> dict[od.ConfigInst, dict[str, dict | set]]: # all variables used in this config in any datacard category "variables": set(), # plain set of names of real data datasets - "data_datasets": set(), + "data_datasets": {}, # per mc dataset name, the set of shift sources and the names processes to be extracted from them "mc_datasets": {}, } @@ -142,7 +152,34 @@ def combined_config_data(self) -> dict[od.ConfigInst, dict[str, dict | set]]: # - data in that category is not faked from mc processes, or # - at least one process object is dynamic (that usually means data-driven) if not cat_obj.data_from_processes or any(proc_obj.is_dynamic for proc_obj in cat_obj.processes): - data["data_datasets"].update(self.get_data_datasets(config_inst, cat_obj)) + for proc_obj in cat_obj.processes: + if not cat_obj.data_from_processes or proc_obj.is_dynamic: + data_datasets = self.get_data_datasets(config_inst, cat_obj) + for dataset_name in data_datasets: + if dataset_name not in data["data_datasets"]: + data["data_datasets"][dataset_name] = { + "shift_sources": set(), + "proc_names": set(), + } + data["data_datasets"][dataset_name]["proc_names"].add( + proc_obj.config_data[config_inst.name].process, + ) + + # shift sources + for param_obj in proc_obj.parameters: + if config_inst.name not in param_obj.config_data: + continue + + # only add if a shift is required for this parameter + if not param_obj.is_dynamic and ( + (param_obj.type.is_shape and not param_obj.transformations.any_from_rate) or + (param_obj.type.is_rate and param_obj.transformations.any_from_shape) + ): + shift_source = param_obj.config_data[config_inst.name].shift_source + for data_dataset in data_datasets: + data_dataset_inst = config_inst.get_dataset(data_dataset) + if shift_source in getattr(data_dataset_inst.x, "known_shift_sources", []): + data["data_datasets"][data_dataset]["shift_sources"].add(shift_source) # mc datasets over all process objects # - the process is not dynamic @@ -215,7 +252,7 @@ def _hist_requirements(self, **kwargs): self, config=config_inst.name, dataset=dataset_name, - shift_sources=(), + shift_sources=tuple(sorted(data["data_datasets"][dataset_name]["shift_sources"])), variables=tuple(sorted(data["variables"])), **kwargs, ) @@ -264,7 +301,11 @@ def load_process_hists( # there must be at least one matching sub process if not any(p.name in h.axes["process"] for p in sub_process_insts): - raise Exception(f"no '{variable}' histograms found for process '{process_inst.name}'") + logger.warning( + f"no '{variable}' histograms found for process '{process_inst.name}'" + f" in dataset {dataset_name} (config {config_inst.name})" + ) + continue # select and reduce over relevant processes h_proc = h[{ diff --git a/columnflow/tasks/histograms.py b/columnflow/tasks/histograms.py index f57e18b14..5d3b00533 100644 --- a/columnflow/tasks/histograms.py +++ b/columnflow/tasks/histograms.py @@ -225,95 +225,109 @@ def run(self): # prepare inputs for localization with law.localize_file_targets([*file_targets, *reader_targets.values()], mode="r") as inps: - for (events, *columns), pos in self.iter_chunked_io( + for (all_events, *columns), pos in self.iter_chunked_io( [inp.abspath for inp in inps], source_type=len(file_targets) * ["awkward_parquet"] + [None] * len(reader_targets), read_columns=(len(file_targets) + len(reader_targets)) * [read_columns], chunk_size=self.hist_producer_inst.get_min_chunk_size(), ): - # optional check for overlapping inputs - if self.check_overlapping_inputs: - self.raise_if_overlapping([events] + list(columns)) - - # add additional columns - events, *columns = remove_obj_overlap(events, *columns) - events = update_ak_array(events, *columns) - - # add aliases - events = add_ak_aliases( - events, - aliases, - remove_src=True, - missing_strategy=self.missing_column_alias_strategy, - ) - # invoke the hist producer, potentially updating columns and creating the event weight - events = attach_coffea_behavior(events) - events, weight = self.hist_producer_inst(events, task=self) + for shift_inst in [self.global_shift_inst, *getattr(self.hist_producer_inst, "sel_subshift_insts", [])]: + if reader_targets: + if shift_inst == self.global_shift_inst: + shift_columns = columns[:len(file_targets) - 1] + else: + shift_columns_indices = [len(file_targets) + i - 1 for i, target in enumerate( + list(reader_targets.values())) if f"/{shift_inst.name}/" in target.path] + shift_columns = [columns[idx] for idx in shift_columns_indices] + else: + shift_columns = columns + + # optional check for overlapping inputs + if self.check_overlapping_inputs: + self.raise_if_overlapping([all_events] + list(shift_columns)) + + # add additional columns + events, *shift_columns = remove_obj_overlap(all_events, *shift_columns) + events = update_ak_array(events, *shift_columns) + + # add aliases + sub_aliases = shift_inst.x("column_aliases", {}) + + events = add_ak_aliases( + events, + sub_aliases, + remove_src=True, + missing_strategy=self.missing_column_alias_strategy, + ) - if len(events) == 0: - self.publish_message(f"no events found in chunk {pos}") - continue + # invoke the hist producer, potentially updating columns and creating the event weight + events = attach_coffea_behavior(events) + events, weight = self.hist_producer_inst(events, task=self) - # merge category ids and check that they are defined as leaf categories - category_ids = ak.concatenate( - [Route(c).apply(events) for c in self.category_id_columns], - axis=-1, - ) - unique_category_ids = np.unique(ak.flatten(category_ids)) - if any(cat_id not in leaf_category_map for cat_id in unique_category_ids): - undefined_category_ids = list(map(str, set(unique_category_ids) - set(leaf_category_map))) - raise ValueError( - f"category_ids column contains ids {','.join(undefined_category_ids)} that are either not " - "known to the config at all, or not as leaf categories (i.e., they have child categories); " - "please ensure that category_ids only contains ids of known leaf categories", - ) + if len(events) == 0: + self.publish_message(f"no events found in chunk {pos}") + continue - # define and fill histograms, taking into account multiple axes - for var_key, var_names in self.variable_tuples.items(): - # get variable instances - variable_insts = [self.config_inst.get_variable(var_name) for var_name in var_names] - - if var_key not in histograms: - # create the histogram in the first chunk - histograms[var_key] = self.hist_producer_inst.run_create_hist(variable_insts, task=self) - - # mask events and weights when selection expressions are found - masked_events = events - masked_weights = weight - masked_category_ids = category_ids - for variable_inst in variable_insts: - sel = variable_inst.selection - if sel == "1": - continue - if not callable(sel): - raise ValueError(f"invalid selection '{sel}', for now only callables are supported") - mask = sel(masked_events) - masked_events = masked_events[mask] - masked_weights = masked_weights[mask] - masked_category_ids = masked_category_ids[mask] - - # broadcast arrays so that each event can be filled for all its categories - fill_data = { - "category": masked_category_ids, - "process": masked_events.process_id, - "shift": self.global_shift_inst.id, - "weight": masked_weights, - } - for variable_inst in variable_insts: - # prepare the expression - expr = variable_inst.expression - if isinstance(expr, str): - route = Route(expr) - def expr(events, *args, **kwargs): - if len(events) == 0 and not has_ak_column(events, route): - return empty_i32 if variable_inst.discrete_x else empty_f32 - return route.apply(events, null_value=variable_inst.null_value) - # apply it - fill_data[variable_inst.name] = expr(masked_events) - - # let the hist producer fill it - self.hist_producer_inst.run_fill_hist(histograms[var_key], fill_data, task=self) + # merge category ids and check that they are defined as leaf categories + category_ids = ak.concatenate( + [Route(c).apply(events) for c in self.category_id_columns], + axis=-1, + ) + unique_category_ids = np.unique(ak.flatten(category_ids)) + if any(cat_id not in leaf_category_map for cat_id in unique_category_ids): + undefined_category_ids = list(map(str, set(unique_category_ids) - set(leaf_category_map))) + raise ValueError( + f"category_ids column contains ids {','.join(undefined_category_ids)} that are either not " + "known to the config at all, or not as leaf categories (i.e., they have child categories); " + "please ensure that category_ids only contains ids of known leaf categories", + ) + + # define and fill histograms, taking into account multiple axes + for var_key, var_names in self.variable_tuples.items(): + # get variable instances + variable_insts = [self.config_inst.get_variable(var_name) for var_name in var_names] + + if var_key not in histograms: + # create the histogram in the first chunk + histograms[var_key] = self.hist_producer_inst.run_create_hist(variable_insts, task=self) + + # mask events and weights when selection expressions are found + masked_events = events + masked_weights = weight + masked_category_ids = category_ids + for variable_inst in variable_insts: + sel = variable_inst.selection + if sel == "1": + continue + if not callable(sel): + raise ValueError(f"invalid selection '{sel}', for now only callables are supported") + mask = sel(masked_events) + masked_events = masked_events[mask] + masked_weights = masked_weights[mask] + masked_category_ids = masked_category_ids[mask] + + # broadcast arrays so that each event can be filled for all its categories + fill_data = { + "category": masked_category_ids, + "process": masked_events.process_id, + "shift": shift_inst.id, + "weight": masked_weights, + } + for variable_inst in variable_insts: + # prepare the expression + expr = variable_inst.expression + if isinstance(expr, str): + route = Route(expr) + def expr(events, *args, **kwargs): + if len(events) == 0 and not has_ak_column(events, route): + return empty_i32 if variable_inst.discrete_x else empty_f32 + return route.apply(events, null_value=variable_inst.null_value) + # apply it + fill_data[variable_inst.name] = expr(masked_events) + + # let the hist producer fill it + self.hist_producer_inst.run_fill_hist(histograms[var_key], fill_data, task=self) # post-process the histograms for var_key in self.variable_tuples.keys(): diff --git a/columnflow/tasks/plotting.py b/columnflow/tasks/plotting.py index ba91c0492..219645d9b 100644 --- a/columnflow/tasks/plotting.py +++ b/columnflow/tasks/plotting.py @@ -145,9 +145,10 @@ def get_config_process_map(self) -> tuple[dict[od.Config, dict[od.Process, dict[ process_info = { "dataset_proc_name_map": dataset_proc_name_map, "config_shifts": { - shift + subshift for dataset_inst in dataset_proc_name_map.keys() for shift in requested_shifts_per_dataset[dataset_inst] + for subshift in (getattr(config_inst.get_shift(shift).x, "subshifts", []) or [shift]) }, } process_shift_map[process_inst.name].update(process_info["config_shifts"]) @@ -209,10 +210,12 @@ def run(self): }] h = h[{"process": sum}] - # create expected shift bins and fill them with the nominal histogram - # change Ghent: replace all expected shifts with nominal. - # not preffered by columnflow: https://github.com/columnflow/columnflow/pull/692 - expected_shifts = plot_shift_names # & process_shift_map[process_inst.name] + if h.sum().value == 0: + continue + # create expected shift bins and fill them with the nominal histogram + # change Ghent: replace all expected shifts with nominal. + # not preffered by columnflow: https://github.com/columnflow/columnflow/pull/692 + expected_shifts = plot_shift_names & process_shift_map[process_inst.name] add_missing_shifts(h, expected_shifts, str_axis="shift", nominal_bin="nominal") # add the histogram @@ -310,6 +313,7 @@ def run(self): lumi = sum([_config_inst.x.luminosity for _config_inst in self.config_insts]) with law.util.patch_object(config_inst.x, "luminosity", lumi): # call the plot function + breakpoint() fig, _ = self.call_plot_func( self.plot_function, hists=hists, @@ -512,8 +516,8 @@ def requires(self): req_cls = lambda dataset_name, config_inst: ( self.reqs.MergeShiftedHistograms - if config_inst.get_dataset(dataset_name).is_mc - else self.reqs.MergeHistograms + # if config_inst.get_dataset(dataset_name).is_mc + # else self.reqs.MergeHistograms ) for config_inst, datasets in zip(self.config_insts, self.datasets): @@ -566,10 +570,15 @@ def get_plot_shifts(self): sources = self.shift_sources if self.combine_shifts else [self.branch_data.shift_source] shifts = [] for source in sources: - shifts.append(get_shift_from_configs(self.config_insts, f"{source}_{od.Shift.UP}")) - shifts.append(get_shift_from_configs(self.config_insts, f"{source}_{od.Shift.DOWN}")) + for direction in (od.Shift.UP, od.Shift.DOWN): + shift = get_shift_from_configs(self.config_insts, f"{source}_{direction}") + if (subshifts := getattr(shift.x, "subshifts", [])): + shifts.extend([get_shift_from_configs(self.config_insts, s) for s in subshifts]) + else: + shifts.append(get_shift_from_configs(self.config_insts, f"{source}_{direction}")) # add nominal + breakpoint() return [self.config_inst.get_shift("nominal"), *shifts] def get_plot_parameters(self): From 0463945e20b4bb08954d892494d03c105a5eacfd Mon Sep 17 00:00:00 2001 From: juvanden Date: Mon, 23 Feb 2026 15:40:26 +0100 Subject: [PATCH 2/5] remove breakpoints --- columnflow/tasks/plotting.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/columnflow/tasks/plotting.py b/columnflow/tasks/plotting.py index 219645d9b..ef2173044 100644 --- a/columnflow/tasks/plotting.py +++ b/columnflow/tasks/plotting.py @@ -313,7 +313,6 @@ def run(self): lumi = sum([_config_inst.x.luminosity for _config_inst in self.config_insts]) with law.util.patch_object(config_inst.x, "luminosity", lumi): # call the plot function - breakpoint() fig, _ = self.call_plot_func( self.plot_function, hists=hists, @@ -578,7 +577,6 @@ def get_plot_shifts(self): shifts.append(get_shift_from_configs(self.config_insts, f"{source}_{direction}")) # add nominal - breakpoint() return [self.config_inst.get_shift("nominal"), *shifts] def get_plot_parameters(self): From fb3fe938f3c44881aebbbc3898c28d7fb4b2e4d9 Mon Sep 17 00:00:00 2001 From: juvanden Date: Mon, 23 Feb 2026 15:52:43 +0100 Subject: [PATCH 3/5] linting fixes --- columnflow/tasks/framework/inference.py | 2 +- tests/test_inference.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/columnflow/tasks/framework/inference.py b/columnflow/tasks/framework/inference.py index 0a3b2d395..63dd135be 100644 --- a/columnflow/tasks/framework/inference.py +++ b/columnflow/tasks/framework/inference.py @@ -303,7 +303,7 @@ def load_process_hists( if not any(p.name in h.axes["process"] for p in sub_process_insts): logger.warning( f"no '{variable}' histograms found for process '{process_inst.name}'" - f" in dataset {dataset_name} (config {config_inst.name})" + f" in dataset {dataset_name} (config {config_inst.name})", ) continue diff --git a/tests/test_inference.py b/tests/test_inference.py index 16e4161f9..a7fd126d5 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -111,6 +111,7 @@ def test_parameter_spec(self): config_shift_source = "test_shift_source" effect = 1.5 effect_precision = 4 + is_dynamic = False # Expected result expected_result = DotDict( @@ -124,6 +125,7 @@ def test_parameter_spec(self): }, effect=effect, effect_precision=effect_precision, + is_dynamic=False, ) # Call the method From 08c08913f33cf6e0fafb3dab6507165ca940ca1f Mon Sep 17 00:00:00 2001 From: juvanden Date: Mon, 23 Feb 2026 15:55:20 +0100 Subject: [PATCH 4/5] linting fixes --- tests/test_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_inference.py b/tests/test_inference.py index a7fd126d5..c9c3bb502 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -125,7 +125,7 @@ def test_parameter_spec(self): }, effect=effect, effect_precision=effect_precision, - is_dynamic=False, + is_dynamic=is_dynamic, ) # Call the method From 68450a651bfd81819b585e4acad5174d17d6ec5c Mon Sep 17 00:00:00 2001 From: juvanden Date: Mon, 23 Feb 2026 16:24:49 +0100 Subject: [PATCH 5/5] fixed the requirements for Monte Carlo in case of collections and removed possibility for collections with data. Instead it will take the subshifts where recognized. --- columnflow/tasks/framework/inference.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/columnflow/tasks/framework/inference.py b/columnflow/tasks/framework/inference.py index 63dd135be..6da45f363 100644 --- a/columnflow/tasks/framework/inference.py +++ b/columnflow/tasks/framework/inference.py @@ -171,7 +171,7 @@ def combined_config_data(self) -> dict[od.ConfigInst, dict[str, dict | set]]: continue # only add if a shift is required for this parameter - if not param_obj.is_dynamic and ( + if ( (param_obj.type.is_shape and not param_obj.transformations.any_from_rate) or (param_obj.type.is_rate and param_obj.transformations.any_from_shape) ): @@ -199,8 +199,9 @@ def combined_config_data(self) -> dict[od.ConfigInst, dict[str, dict | set]]: for param_obj in proc_obj.parameters: if config_inst.name not in param_obj.config_data: continue + # only add if a shift is required for this parameter - if ( + if not param_obj.is_dynamic & ( (param_obj.type.is_shape and not param_obj.transformations.any_from_rate) or (param_obj.type.is_rate and param_obj.transformations.any_from_shape) ): @@ -303,7 +304,7 @@ def load_process_hists( if not any(p.name in h.axes["process"] for p in sub_process_insts): logger.warning( f"no '{variable}' histograms found for process '{process_inst.name}'" - f" in dataset {dataset_name} (config {config_inst.name})", + f" in dataset {dataset_name} (config {config_inst.name})" ) continue