# coding: utf-8
"""
Jet energy corrections and jet resolution smearing.
"""
from __future__ import annotations
import dataclasses
import functools
import law
from columnflow.calibration import Calibrator, calibrator
from columnflow.calibration.util import ak_random, propagate_met, sum_transverse
from columnflow.production.util import attach_coffea_behavior
from columnflow.util import UNSET, maybe_import, DotDict, load_correction_set
from columnflow.columnar_util import (
set_ak_column, layout_ak_array, optional_column as optional, ak_concatenate_safe, TAFConfig, Route,
)
from columnflow.types import TYPE_CHECKING, Any, Sequence, Callable
np = maybe_import("numpy")
ak = maybe_import("awkward")
if TYPE_CHECKING:
correctionlib = maybe_import("correctionlib")
logger = law.logger.get_logger(__name__)
#
# helper functions
#
set_ak_column_f32 = functools.partial(set_ak_column, value_type=np.float32)
[docs]
def get_evaluators(
correction_set: correctionlib.highlevel.CorrectionSet,
names: list[str | tuple[str, ...]],
attrs: list[dict[str, Any]] | None = None,
) -> list[correctionlib.highlevel.Correction | tuple[correctionlib.highlevel.Correction, ...]]:
"""
Helper function to get a list of correction evaluators from
:external+correctionlib:py:class:`correctionlib.highlevel.CorrectionSet` object given a list of *names*. The *names*
can refer to either simple or compound corrections.
:param correction_set: evaluator provided by :external+correctionlib:doc:`index`
:param names: List of names of corrections to be applied
:param: attrs: List of dictionaries containing attributes to be added to each evaluator.
:raises RuntimeError: If a requested correction in *names* is not available
:return: List of compounded corrections, see
:external+correctionlib:py:class:`correctionlib.highlevel.CorrectionSet`
"""
# raise nice error if keys not found
flat_names = law.util.flatten(names)
available_keys = set(correction_set.keys()).union(correction_set.compound.keys())
missing_keys = set(flat_names) - available_keys
if missing_keys:
raise RuntimeError("corrections not found:" + "".join(
f"\n - {name}" for name in flat_names if name in missing_keys
) + "\navailable:" + "".join(
f"\n - {name}" for name in sorted(available_keys)
))
if attrs and len(attrs) != len(names):
raise ValueError(
f"number of attribute dictionaries ({len(attrs)}) does not match number of evaluator names ({len(names)})",
)
# retrieve the evaluators
evaluators = []
for i, name in enumerate(names):
single = isinstance(name, str)
name = law.util.make_tuple(name)
evals = [(correction_set.compound[n] if n in correction_set.compound else correction_set[n]) for n in name]
# attach attributes if given
if attrs:
for attr, value in attrs[i].items():
for _name in name:
if value not in _name:
raise ValueError(f"attribute value '{value}' not found in evaluator name '{_name}'")
for e in evals:
setattr(e, attr, value)
# save
evaluators.append(evals[0] if single else evals)
return evaluators
[docs]
def ak_evaluate(evaluator: correctionlib.highlevel.Correction, *args) -> float:
"""
Evaluate a :external+correctionlib:py:class:`correctionlib.highlevel.Correction` using one or more
:external+ak:py:class:`awkward arrays <ak.Array>` as inputs.
:param evaluator: Evaluator instance
:raises ValueError: If no :external+ak:py:class:`awkward arrays <ak.Array>` are provided
:return: The correction factor derived from the input arrays
"""
# fail if no arguments
if not args:
raise ValueError("expected at least one argument")
# collect arguments that are awkward arrays
ak_args = [
arg for arg in args if isinstance(arg, ak.Array)
]
# broadcast akward arrays together and flatten
if ak_args:
bc_args = ak.broadcast_arrays(*ak_args)
flat_args = (
np.asarray(ak.flatten(bc_arg, axis=None))
for bc_arg in bc_args
)
output_layout_array = bc_args[0]
else:
flat_args = iter(())
output_layout_array = None
# multiplex flattened and non-awkward inputs
all_flat_args = [
next(flat_args) if isinstance(arg, ak.Array) else arg
for arg in args
]
# apply evaluator to flattened/multiplexed inputs
result = evaluator.evaluate(*all_flat_args)
# apply broadcasted layout to result
if output_layout_array is not None:
result = layout_ak_array(result, output_layout_array)
return result
#
# jet energy corrections
#
@dataclasses.dataclass
class BJECConfig(TAFConfig):
"""
Container object to hold configuration for b-jet energy corrections (BJECs). Example:
.. code-block:: python
# for PNet regressed jets
BJECConfig(
jet_types=("AK4PFPuppiPNetRegressionPlusNeutrino", "AK4PFPuppiPNetRegression"),
regr_factors=("PNetRegPtRawCorrNeutrino", "PNetRegPtRawCorr"),
bjet_selection=(lambda events: events.Jet.btagPNetB > 0.245),
bjet_selection_columns={"btagPNetB"},
)
Resources:
- https://cms-jerc.web.cern.ch/ExpJEC/#jec-for-pnet-and-upart-regressed-jets
- https://cms-jerc.web.cern.ch/JES/#remarks-on-getting-rawpt-and-mass-for-regular-pnet-and-upart-jets
"""
# tagged and untagged jet types in correction set names
jet_types: tuple[str, str]
# regression factors to be applied to tagged and untagged jets
regr_factors: tuple[str, str]
# function to create a mask to select bjets among all jets
bjet_selection: Callable[[ak.Array], ak.array | np.ndarray]
# jet columns needed by bjet_selection
bjet_selection_columns: Sequence[str | Route] | set[str, Route] = dataclasses.field(default_factory=set)
def __post_init__(self) -> None:
if len(self.jet_types) != 2:
raise ValueError(f"number of jet_types must be 2, found '{self.jet_types}'")
if len(self.regr_factors) != 2:
raise ValueError(f"number of regr_factors must be 2, found '{self.regr_factors}'")
@dataclasses.dataclass
class JECConfig(TAFConfig):
"""
Container object to hold configuration for jet energy corrections. Example:
.. code-block:: python
JECConfig(
jet_name="Jet",
jet_type="AK4PFchs",
campaign="Summer19UL17",
version="V5",
levels=["L1L2L3Res"], # or individual correction levels
levels_for_type1_met=["L1FastJet"],
uncertainty_sources=[
"Total",
"CorrelationGroupMPFInSitu",
"CorrelationGroupIntercalibration",
"CorrelationGroupbJES",
"CorrelationGroupFlavor",
"CorrelationGroupUncorrelated",
],
bjec_config=... # see :py:class:`~.BJECConfig`
)
"""
jet_name: str
jet_type: str
campaign: str
version: str
levels: list[str] = dataclasses.field(
default_factory=(lambda: ["L1FastJet", "L2Relative", "L2L3Residual", "L3Absolute"]),
)
levels_for_type1_met: list[str] = dataclasses.field(default_factory=lambda: ["L1FastJet"])
uncertainty_sources: list[str] = dataclasses.field(default_factory=list)
external_file_key: str | None = None
data_per_era: bool = False
bjec_config: BJECConfig | None = None
@classmethod
def new(cls, obj: JECConfig | dict[str, Any]) -> JECConfig:
# purely for backwards compatibility with the old dict format
if isinstance(obj, cls):
return obj
if isinstance(obj, dict):
return cls(**obj)
raise ValueError(f"cannot convert {obj} to {cls.__name__}")
# define default functions for jec calibrator
def get_jerc_file_default(self: Calibrator, external_files: DotDict, *, get_config_attr: str) -> str:
"""
Function to obtain external correction files for JEC and/or JER.
By default, this function extracts the location of the jec correction files from the current config instance
*config_inst*. The key of the external file depends on the jet collection. For ``Jet`` (AK4 jets), this resolves to
``jet_jerc``, and for ``FatJet`` it is resolved to ``fat_jet_jerc``.
.. code-block:: python
cfg.x.external_files = DotDict.wrap({
"jet_jerc": "/afs/cern.ch/work/m/mrieger/public/mirrors/jsonpog-integration-9ea86c4c/POG/JME/2017_UL/jet_jerc.json.gz",
"fat_jet_jerc": "/afs/cern.ch/work/m/mrieger/public/mirrors/jsonpog-integration-9ea86c4c/POG/JME/2017_UL/fatJet_jerc.json.gz",
})
:param external_files: Dictionary containing the information about the file location
:return: path or url to correction file(s)
""" # noqa
# get config
jerc_config = getattr(self, get_config_attr)()
# first check config for user-supplied `external_file_key`
if jerc_config.external_file_key is not None:
return external_files[jerc_config.external_file_key]
# if not found, try to resolve from jet collection name and fail if not standard NanoAOD
if self.jet_name not in get_jerc_file_default.map_jet_name_file_key:
available_keys = ", ".join(sorted(get_jerc_file_default.map_jet_name_file_key))
raise ValueError(
f"could not determine external file key for jet collection '{self.jet_name}', name is not one of standard "
f"NanoAOD jet collections: {available_keys}",
)
# return external file
ext_file_key = get_jerc_file_default.map_jet_name_file_key[self.jet_name]
return external_files[ext_file_key]
# default external file keys for known jet collections
get_jerc_file_default.map_jet_name_file_key = {
"Jet": "jet_jerc",
"FatJet": "fat_jet_jerc",
}
def get_jec_config_default(self: Calibrator) -> JECConfig:
"""
Load config relevant to the jet energy corrections (JEC).
By default, this is extracted from the current *config_inst*, assuming the JEC configurations are stored under the
'jec' aux key. Separate configurations should be specified for each jet collection, using the collection name as a
key. For example, the configuration for the default jet collection ``Jet`` will be retrieved from the following
config entry:
.. code-block:: python
self.config_inst.x.jec.Jet
Used in :py:meth:`~.jec.setup_func`.
:return: Dictionary containing configuration for jet energy calibration
"""
def cast(jet_name: str, obj: Any) -> JECConfig:
if isinstance(obj, dict):
obj = {"jet_name": jet_name, **obj}
return JECConfig.new(obj)
jec_cfg = self.config_inst.x.jec
# check for old-style config
if self.jet_name not in jec_cfg:
# if jet collection is `Jet`, issue deprecation warning
if self.jet_name == "Jet":
logger.warning_once(
f"{id(self)}_depr_jec_config",
"config aux 'jec' does not contain key for input jet "
f"collection '{self.jet_name}'. This may be due to an outdated config. Continuing under the assumption "
"that the entire 'jec' entry refers to this jet collection. This assumption will be removed in future "
"versions of columnflow, so please adapt the config according to the documentation to remove this "
"warning and ensure future compatibility of the code.",
)
return cast(self.jet_name, jec_cfg)
# otherwise raise exception
raise ValueError(f"config aux 'jec' does not contain key for input jet collection '{self.jet_name}'")
return cast(self.jet_name, jec_cfg[self.jet_name])
[docs]
@calibrator(
uses={
"run",
optional("fixedGridRhoFastjetAll"),
optional("Rho.fixedGridRhoFastjetAll"),
attach_coffea_behavior,
},
# name of the jet collection to calibrate
jet_name="Jet",
# name of the associated MET collection
met_name="MET", # TODO: move to use JECConfig.met_name?
# name of the associated Raw MET collection
raw_met_name="RawMET", # TODO: move to JECConfig.raw_met_name?
# custom uncertainty sources, defaults to config when empty
uncertainty_sources=None, # TODO: solely use JECConfig.uncertainty_sources?
# toggle for propagation to MET
propagate_met=True, # TODO: move to JECConfig.propagate_met?
# function to determine the correction file
get_jec_file=functools.partialmethod(get_jerc_file_default, get_config_attr="get_jec_config"),
# function to determine the jec configuration dict
get_jec_config=get_jec_config_default,
# function to update variables before jec corrector call
update_corrector_variables=(lambda self, corrector, variables: variables), # TODO: move to JECConfig.update_corrector_variables? # noqa
)
def jec(
self: Calibrator,
events: ak.Array,
min_pt_met_prop: float = 15.0, # TODO: move to JECConfig.min_pt_met_prop?
max_eta_met_prop: float = 5.2, # TODO: move to JECConfig.max_eta_met_prop?
**kwargs,
) -> ak.Array:
"""
Performs the jet energy corrections (JECs) and uncertainty shifts using the :external+correctionlib:doc:`index`,
optionally propagating the changes to the MET.
The *jet_name* should be set to the name of the NanoAOD jet collection to calibrate (default: ``Jet``, i.e. AK4
jets).
Requires an external file in the config pointing to the JSON files containing the JECs. The file key can be
specified via an optional ``external_file_key`` in the ``jec`` config entry. If not given, the file key will be
determined automatically based on the jet collection name: ``jet_jerc`` for ``Jet`` (AK4 jets), ``fat_jet_jerc``
for``FatJet`` (AK8 jets). A full set of JSON files can be specified as:
.. code-block:: python
cfg.x.external_files = DotDict.wrap({
"jet_jerc": "/afs/cern.ch/work/m/mrieger/public/mirrors/jsonpog-integration-9ea86c4c/POG/JME/2017_UL/jet_jerc.json.gz",
"fat_jet_jerc": "/afs/cern.ch/work/m/mrieger/public/mirrors/jsonpog-integration-9ea86c4c/POG/JME/2017_UL/fatJet_jerc.json.gz",
})
For more file-grained control, the *get_jec_file* can be adapted in a subclass in case it is stored differently in
the external files
The JEC configuration should be an auxiliary entry in the config, specifying the correction details under "jec".
Separate configs should be given for each jet collection to calibrate, using the jet collection name as a subkey. An
example of a valid configuration for correction AK4 jets with JEC is:
.. code-block:: python
cfg.x.jec = {
"Jet": JECConfig(
jet_name="Jet",
jet_type="AK4PFchs",
campaign="Summer19UL17",
version="V5",
levels=["L1L2L3Res"], # or individual correction levels
levels_for_type1_met=["L1FastJet"],
uncertainty_sources=[
"Total",
"CorrelationGroupMPFInSitu",
"CorrelationGroupIntercalibration",
"CorrelationGroupbJES",
"CorrelationGroupFlavor",
"CorrelationGroupUncorrelated",
],
},
}
*get_jec_config* can be adapted in a subclass in case it is stored differently in the config.
If running on data, the datasets must have an auxiliary field *jec_era* defined, e.g. "RunF", or an auxiliary field
*era*, e.g. "F".
This instance of :py:class:`~columnflow.calibration.Calibrator` is initialized with the following parameters by
default:
:param events: awkward array containing events to process
:param min_pt_met_prop: If *propagate_met* variable is ``True`` propagate the updated jet values to the missing
transverse energy (MET) using :py:func:`~columnflow.calibration.util.propagate_met` for events where ``met.pt >
*min_pt_met_prop*``.
:param max_eta_met_prop: If *propagate_met* variable is ``True`` propagate the updated jet values to the missing
transverse energy (MET) using :py:func:`~columnflow.calibration.util.propagate_met` for events where ``met.eta >
*min_eta_met_prop*``.
Resources:
- https://cms-jerc.web.cern.ch/Recommendations/#jet-energy-scale
- https://cms-jerc.web.cern.ch/ExpJEC/#jec-for-pnet-and-upart-regressed-jets
- https://cms-jerc.web.cern.ch/JES/#remarks-on-getting-rawpt-and-mass-for-regular-pnet-and-upart-jets
""" # noqa
# use local variable for convenience
jet_name = self.jet_name
# build bjet selection and regression factor
if self.bjec_cfg is None:
regr_factor = ak.ones_like(events[jet_name].pt, dtype=np.float32)
else:
bjet_mask = self.bjec_cfg.bjet_selection(events)
regr_factor = ak.where(
bjet_mask,
events[jet_name][self.bjec_cfg.regr_factors[0]],
events[jet_name][self.bjec_cfg.regr_factors[1]],
)
# calculate uncorrected pt, mass
events = set_ak_column_f32(
events,
f"{jet_name}.pt_raw",
events[jet_name].pt * (1 - events[jet_name].rawFactor) * regr_factor,
)
events = set_ak_column_f32(
events,
f"{jet_name}.mass_raw",
events[jet_name].mass * (1 - events[jet_name].rawFactor) * regr_factor,
)
def correct_jets(*, pt, eta, phi, area, rho, run, evaluator_key="jec"):
# variable naming convention
variable_map = {
"JetA": area,
"JetEta": eta,
"JetPt": pt,
"JetPhi": phi,
"Rho": ak.values_astype(rho, np.float32),
"run": run,
}
# apply all evaluators sequentially, updating the pt each time
full_correction = ak.ones_like(pt, dtype=np.float32)
for evaluator in self.evaluators[evaluator_key]:
_evaluator = evaluator if self.bjec_cfg is None else evaluator[0]
# optionally update variables for this evaluator call
_variable_map = variable_map
if callable(self.update_corrector_variables):
_variable_map = variable_map.copy()
_variable_map = self.update_corrector_variables(_evaluator, _variable_map)
# determine correct inputs (change depending on evaluator)
inputs = [_variable_map[inp.name] for inp in _evaluator.inputs]
if self.bjec_cfg is None:
correction = ak_evaluate(evaluator, *inputs)
else:
correction = ak.where(
bjet_mask,
ak_evaluate(evaluator[0], *inputs),
ak_evaluate(evaluator[1], *inputs),
)
# update pt in original variable map for subsequent evaluators
variable_map["JetPt"] = variable_map["JetPt"] * correction
full_correction = full_correction * correction
return full_correction
# obtain rho, which might be located at different routes, depending on the nano version
rho = (
events.fixedGridRhoFastjetAll
if "fixedGridRhoFastjetAll" in events.fields
else events.Rho.fixedGridRhoFastjetAll
)
# correct jets with only a subset of correction levels
# (for calculating TypeI MET correction)
if self.propagate_met:
# get correction factors
jec_factors_subset_type1_met = correct_jets(
pt=events[jet_name].pt_raw,
eta=events[jet_name].eta,
phi=events[jet_name].phi,
area=events[jet_name].area,
rho=rho,
run=events.run,
evaluator_key="jec_subset_type1_met",
)
# temporarily apply the new factors with only subset of corrections
events = set_ak_column_f32(events, f"{jet_name}.pt", events[jet_name].pt_raw * jec_factors_subset_type1_met)
events = set_ak_column_f32(events, f"{jet_name}.mass", events[jet_name].mass_raw * jec_factors_subset_type1_met)
events = self[attach_coffea_behavior](events, collections=[jet_name], **kwargs)
# store pt and phi of the full jet system for MET propagation, including a selection in raw info
# see https://twiki.cern.ch/twiki/bin/view/CMS/JECAnalysesRecommendations?rev=19#Minimum_jet_selection_cuts
met_prop_mask = (events[jet_name].pt_raw > min_pt_met_prop) & (abs(events[jet_name].eta) < max_eta_met_prop)
jetsum = events[jet_name][met_prop_mask].sum(axis=1)
jetsum_pt_subset_type1_met = jetsum.pt
jetsum_phi_subset_type1_met = jetsum.phi
# factors for full jet correction with all levels
jec_factors = correct_jets(
pt=events[jet_name].pt_raw,
eta=events[jet_name].eta,
phi=events[jet_name].phi,
area=events[jet_name].area,
rho=rho,
run=events.run,
evaluator_key="jec",
)
# apply full jet correction
events = set_ak_column_f32(events, f"{jet_name}.pt", events[jet_name].pt_raw * jec_factors)
events = set_ak_column_f32(events, f"{jet_name}.mass", events[jet_name].mass_raw * jec_factors)
raw_factor = ak.nan_to_num(1 - events[jet_name].pt_raw / events[jet_name].pt, nan=0.0)
events = set_ak_column_f32(events, f"{jet_name}.rawFactor", raw_factor)
events = self[attach_coffea_behavior](events, collections=[jet_name], **kwargs)
# nominal met propagation
if self.propagate_met:
# get pt and phi of all jets after correcting
jetsum = events[jet_name][met_prop_mask].sum(axis=1)
jetsum_pt_all_levels = jetsum.pt
jetsum_phi_all_levels = jetsum.phi
# propagate changes to MET, starting from jets corrected with subset of JEC levels
# (recommendation is to propagate only L2 corrections and onwards)
met_pt, met_phi = propagate_met(
jetsum_pt_subset_type1_met,
jetsum_phi_subset_type1_met,
jetsum_pt_all_levels,
jetsum_phi_all_levels,
events[self.raw_met_name].pt,
events[self.raw_met_name].phi,
)
events = set_ak_column_f32(events, f"{self.met_name}.pt", met_pt)
events = set_ak_column_f32(events, f"{self.met_name}.phi", met_phi)
# variable naming conventions
variable_map = {
"JetEta": events[jet_name].eta,
"JetPt": events[jet_name].pt_raw,
}
# jet energy uncertainty components
for name, evaluator in self.evaluators["junc"].items():
# get uncertainty
_evaluator = evaluator if self.bjec_cfg is None else evaluator[0]
inputs = [variable_map[inp.name] for inp in _evaluator.inputs]
if self.bjec_cfg is None:
jec_uncertainty = ak_evaluate(evaluator, *inputs)
else:
jec_uncertainty = ak.where(
bjet_mask,
ak_evaluate(evaluator[0], *inputs),
ak_evaluate(evaluator[1], *inputs),
)
# apply jet uncertainty shifts
events = set_ak_column_f32(
events, f"{jet_name}.pt_jec_{name}_up", events[jet_name].pt * (1.0 + jec_uncertainty),
)
events = set_ak_column_f32(
events, f"{jet_name}.pt_jec_{name}_down", events[jet_name].pt * (1.0 - jec_uncertainty),
)
events = set_ak_column_f32(
events, f"{jet_name}.mass_jec_{name}_up", events[jet_name].mass * (1.0 + jec_uncertainty),
)
events = set_ak_column_f32(
events, f"{jet_name}.mass_jec_{name}_down", events[jet_name].mass * (1.0 - jec_uncertainty),
)
# propagate shifts to MET
if self.propagate_met:
jet_pt_up = events[jet_name][met_prop_mask][f"pt_jec_{name}_up"]
jet_pt_down = events[jet_name][met_prop_mask][f"pt_jec_{name}_down"]
met_pt_up, met_phi_up = propagate_met(
jetsum_pt_all_levels,
jetsum_phi_all_levels,
jet_pt_up,
events[jet_name][met_prop_mask].phi,
met_pt,
met_phi,
)
met_pt_down, met_phi_down = propagate_met(
jetsum_pt_all_levels,
jetsum_phi_all_levels,
jet_pt_down,
events[jet_name][met_prop_mask].phi,
met_pt,
met_phi,
)
events = set_ak_column_f32(events, f"{self.met_name}.pt_jec_{name}_up", met_pt_up)
events = set_ak_column_f32(events, f"{self.met_name}.pt_jec_{name}_down", met_pt_down)
events = set_ak_column_f32(events, f"{self.met_name}.phi_jec_{name}_up", met_phi_up)
events = set_ak_column_f32(events, f"{self.met_name}.phi_jec_{name}_down", met_phi_down)
return events
@jec.init
def jec_init(self: Calibrator, **kwargs) -> None:
super(jec, self).init_func(**kwargs)
# load configs
self.jec_cfg = self.get_jec_config()
self.bjec_cfg = self.jec_cfg.bjec_config
sources = self.uncertainty_sources
if sources is None:
sources = self.jec_cfg.uncertainty_sources or []
self.uncertainty_sources = sources
# register used jet columns
self.uses.add(f"{self.jet_name}.{{pt,eta,phi,mass,area,rawFactor}}")
# add columns needed for bjet regression if needed
if self.bjec_cfg is not None:
self.uses.add(f"{self.jet_name}.{{{','.join(self.bjec_cfg.regr_factors)}}}")
self.uses.add(f"{self.jet_name}.{{{','.join(self.bjec_cfg.bjet_selection_columns)}}}")
# register produced jet columns
self.produces.add(f"{self.jet_name}.{{pt,mass,rawFactor}}")
# add shifted jet variables
self.produces |= {
f"{self.jet_name}.{shifted_var}_jec_{junc_name}_{junc_dir}"
for shifted_var in ("pt", "mass")
for junc_name in sources
for junc_dir in ("up", "down")
}
# add MET variables
if self.propagate_met:
self.uses.add(f"{self.raw_met_name}.{{pt,phi}}")
self.produces.add(f"{self.met_name}.{{pt,phi}}")
# add shifted MET variables
self.produces |= {
f"{self.met_name}.{shifted_var}_jec_{junc_name}_{junc_dir}"
for shifted_var in ("pt", "phi")
for junc_name in sources
for junc_dir in ("up", "down")
}
@jec.requires
def jec_requires(
self: Calibrator,
task: law.Task,
reqs: dict[str, DotDict[str, Any]],
**kwargs,
) -> None:
super(jec, self).requires_func(task=task, reqs=reqs, **kwargs)
if "external_files" in reqs:
return
from columnflow.tasks.external import BundleExternalFiles
reqs["external_files"] = BundleExternalFiles.req(task)
@jec.setup
def jec_setup(
self: Calibrator,
task: law.Task,
reqs: dict[str, DotDict[str, Any]],
inputs: dict[str, Any],
reader_targets: law.util.InsertableDict,
**kwargs,
) -> None:
"""
Load the correct jec files using the :py:func:`from_string` method of the
:external+correctionlib:py:class:`correctionlib.highlevel.CorrectionSet` function and apply the corrections as
needed.
The source files for the :external+correctionlib:py:class:`correctionlib.highlevel.CorrectionSet` instance are
extracted with the :py:meth:`~.jec.get_jec_file`.
Uses the member function :py:meth:`~.jec.get_jec_config` to construct the required keys, which are based on the
following information about the JEC:
- levels
- campaign
- version
- jet_type
A corresponding example snippet wihtin the *config_inst* could like something like this:
.. code-block:: python
cfg.x.jec = DotDict.wrap({
"Jet": {
# campaign name for this JEC correctiono
"campaign": f"Summer19UL{year2}{jerc_postfix}",
# version of the corrections
"version": "V7",
# Type of jets that the corrections should be applied on
"jet_type": "AK4PFchs",
# relevant levels in the derivation process of the JEC
"levels": ["L1FastJet", "L2Relative", "L2L3Residual", "L3Absolute"],
# relevant levels in the derivation process of the Type 1 MET JEC
"levels_for_type1_met": ["L1FastJet"],
# names of the uncertainties to be applied
"uncertainty_sources": [
"Total",
"CorrelationGroupMPFInSitu",
"CorrelationGroupIntercalibration",
"CorrelationGroupbJES",
"CorrelationGroupFlavor",
"CorrelationGroupUncorrelated",
],
# whether the JECs for data should be era-specific
"data_per_era": True,
},
})
:param reqs: Requirement dictionary for this :py:class:`~columnflow.calibration.Calibrator` instance
:param inputs: Additional inputs, currently not used
:param reader_targets: TODO: add documentation
"""
super(jec, self).setup_func(task=task, reqs=reqs, inputs=inputs, reader_targets=reader_targets, **kwargs)
# import the correction sets from the external file
jec_file = self.get_jec_file(reqs["external_files"].files)
correction_set = load_correction_set(jec_file)
def make_jme_keys(names, jet_types, jec_cfg=self.jec_cfg, is_data=self.dataset_inst.is_data):
if is_data and jec_cfg.data_per_era:
jec_era = self.dataset_inst.get_aux("jec_era", None)
# if no special JEC era is specified, infer based on 'era'
if jec_era is None:
era = self.dataset_inst.get_aux("era", None)
if era is None:
raise ValueError(
"JEC data key is requested to be era dependent, but neither jec_era or era auxiliary is set "
f"for dataset {self.dataset_inst.name}",
)
jec_era = "Run" + era
jme_key = f"{jec_cfg.campaign}_{jec_era}_{jec_cfg.version}_DATA_{{name}}_{{jet_type}}"
elif is_data:
jme_key = f"{jec_cfg.campaign}_{jec_cfg.version}_DATA_{{name}}_{{jet_type}}"
else: # MC
jme_key = f"{jec_cfg.campaign}_{jec_cfg.version}_MC_{{name}}_{{jet_type}}"
keys = []
for name in names:
for jet_type in jet_types:
assert isinstance(jet_type, (str, tuple))
keys.append(
jme_key.format(name=name, jet_type=jet_type)
if isinstance(jet_type, str)
else tuple(jme_key.format(name=name, jet_type=_jet_type) for _jet_type in jet_type),
)
return keys
jec_keys = make_jme_keys(
self.jec_cfg.levels,
[self.jec_cfg.jet_type] if self.bjec_cfg is None else [self.bjec_cfg.jet_types],
)
jec_keys_subset_type1_met = make_jme_keys(
self.jec_cfg.levels_for_type1_met,
[self.jec_cfg.jet_type] if self.bjec_cfg is None else [self.bjec_cfg.jet_types],
)
junc_keys = make_jme_keys(
self.uncertainty_sources,
[self.jec_cfg.jet_type] if self.bjec_cfg is None else [self.bjec_cfg.jet_types],
is_data=False, # uncertainties only stored as MC keys
)
# store the evaluators
self.evaluators = {
"jec": get_evaluators(
correction_set,
jec_keys,
attrs=[{"level": level} for level in self.jec_cfg.levels],
),
"jec_subset_type1_met": get_evaluators(
correction_set,
jec_keys_subset_type1_met,
attrs=[{"level": level} for level in self.jec_cfg.levels_for_type1_met],
),
"junc": dict(zip(self.uncertainty_sources, get_evaluators(correction_set, junc_keys))),
}
# custom jec calibrator that only runs nominal correction
jec_nominal = jec.derive("jec_nominal", cls_dict={"uncertainty_sources": []})
# explicit calibrators for standard jet collections
jec_ak4 = jec.derive("jec_ak4", cls_dict={"jet_name": "Jet"})
jec_ak8 = jec.derive("jec_ak8", cls_dict={"jet_name": "FatJet", "propagate_met": False})
jec_ak4_nominal = jec_ak4.derive("jec_ak4", cls_dict={"uncertainty_sources": []})
jec_ak8_nominal = jec_ak8.derive("jec_ak8", cls_dict={"uncertainty_sources": []})
#
# jet energy resolution smearing
#
@dataclasses.dataclass
class JERConfig(TAFConfig):
jet_name: str
jet_type: str
campaign: str
version: str
external_file_key: str | None = None
use_jer_tool: bool = False
uncertainty_regions: dict[str, Callable[[ak.Array], ak.Array]] = dataclasses.field(default_factory=dict)
@classmethod
def new(cls, obj: JERConfig | dict[str, Any]) -> JERConfig:
# purely for backwards compatibility with the old dict format
if isinstance(obj, cls):
return obj
if isinstance(obj, dict):
return cls(**obj)
raise ValueError(f"cannot convert {obj} to {cls.__name__}")
def get_jer_config_default(self: Calibrator) -> DotDict:
"""
Load config relevant to the jet energy resolution (JER) smearing.
By default, this is extracted from the current *config_inst*, assuming the JER configurations are stored under the
'jer' aux key. Separate configurations should be specified for each jet collection, using the collection name as a
key. For example, the configuration for the default jet collection ``Jet`` will be retrieved from the following
config entry:
.. code-block:: python
self.config_inst.x.jer.Jet
Used in :py:meth:`~.jer.setup_func`.
:return: Dictionary containing configuration for JER smearing
"""
def cast(jet_name: str, obj: Any) -> JECConfig:
if isinstance(obj, dict):
obj = {"jet_name": jet_name, **obj}
return JERConfig.new(obj)
jer_cfg = self.config_inst.x.jer
# check for old-style config
if self.jet_name not in jer_cfg:
# if jet collection is `Jet`, issue deprecation warning
if self.jet_name == "Jet":
logger.warning_once(
f"{id(self)}_depr_jer_config",
f"config aux 'jer' does not contain key for input jet collection '{self.jet_name}'. This may be due to "
"an outdated config. Continuing under the assumption that the entire 'jer' entry refers to this jet "
"collection. This assumption will be removed in future versions of columnflow, so please adapt the "
"config according to the documentation to remove this warning and ensure future compatibility of the "
"code.",
)
return cast(self.jet_name, jer_cfg)
# otherwise raise exception
raise ValueError(f"config aux 'jer' does not contain key for input jet collection '{self.jet_name}'")
return cast(self.jet_name, jer_cfg[self.jet_name])
[docs]
@calibrator(
uses={
optional("Rho.fixedGridRhoFastjetAll"),
optional("fixedGridRhoFastjetAll"),
attach_coffea_behavior,
},
# name of the jet collection to smear
jet_name="Jet",
# name of the associated gen jet collection
gen_jet_name="GenJet", # TODO: move to JERConfig.gen_jet_name?
# name of the associated MET collection
met_name="MET", # TODO: move to JERConfig.met_name?
# toggle for propagation to MET
propagate_met=True, # TODO: move to JERConfig.propagate_met?
# use deterministic seeds for random smearing and take the "index"-th random number per seed when not -1
# (only allowed when not using the central jer smearing tool, see JERConfig and get_jer_tool_file)
deterministic_seed_index=-1,
# function to determine the correction file
get_jer_file=functools.partialmethod(get_jerc_file_default, get_config_attr="get_jer_config"),
# function to determine the jer configuration dict
get_jer_config=get_jer_config_default,
# function to determine the jec configuration dict
get_jec_config=get_jec_config_default,
# function to get the jer smearing tool (correctionlib) file from the config
# (see https://cms-jerc.web.cern.ch/JER/#smearing-procedures)
get_jer_tool_file=(lambda self, external_files: external_files.jer_tool),
# jec uncertainty sources to propagate jer to, defaults to config when empty
jec_uncertainty_sources=None, # TODO: solely use JECConfig.uncertainty_sources?
# whether gen jet matching should be performed relative to the nominal jet pt, or the jec varied values
gen_jet_matching_nominal=False, # TODO: move to JERConfig.gen_jet_matching_nominal?
# regions where stochastic smearing is applied
stochastic_smearing_mask=lambda self, jets: ak.ones_like(jets.pt, dtype=bool), # TODO: move to JERConfig.stochastic_smearing_mask? # noqa
# only run on mc
mc_only=True,
)
def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
"""
Applies the jet energy resolution smearing in MC and calculates the associated uncertainty shifts using the
:external+correctionlib:doc:`index`, following the recommendations given in
https://twiki.cern.ch/twiki/bin/viewauth/CMS/JetResolution.
The *jet_name* and *gen_jet_name* should be set to the name of the NanoAOD jet and gen jet collections to use as an
input for JER smearing (default: ``Jet`` and ``GenJet``, respectively, i.e. AK4 jets).
Requires an external file in the config pointing to the JSON files containing the JER information. The file key can
be specified via an optional ``external_file_key`` in the ``jer`` config entry. If not given, the file key will be
determined automatically based on the jet collection name: ``jet_jerc`` for ``Jet`` (AK4 jets), ``fat_jet_jerc``
for``FatJet`` (AK8 jets). A full set of JSON files can be specified as:
.. code-block:: python
cfg.x.external_files = DotDict.wrap({
"jet_jerc": "/afs/cern.ch/work/m/mrieger/public/mirrors/jsonpog-integration-9ea86c4c/POG/JME/2017_UL/jet_jerc.json.gz",
"fat_jet_jerc": "/afs/cern.ch/work/m/mrieger/public/mirrors/jsonpog-integration-9ea86c4c/POG/JME/2017_UL/fatJet_jerc.json.gz",
})
For more fine-grained control, the *get_jer_file* can be adapted in a subclass in case it is stored differently in
the external files.
The JER smearing configuration should be an auxiliary entry in the config, specifying the input JER to use under
"jer". Separate configs should be given for each jet collection to smear, using the jet collection name as a subkey.
An example of a valid configuration for smearing AK4 jets with JER is:
.. code-block:: python
cfg.x.jer = {
"Jet": {
"campaign": "Summer19UL17",
"version": "JRV2",
"jet_type": "AK4PFchs",
},
}
*get_jer_config* can be adapted in a subclass in case it is stored differently in the config.
The nominal JER smearing is performed on nominal jets as well as those varied as a result of jet energy corrections.
For this purpose, *get_jec_config* and *jec_uncertainty_sources* can be defined to control the considered
variations. Consequently, the matching of jets to gen jets which depends on pt values of the former is subject to a
choice regarding which pt values to use. If *gen_jet_matching_nominal* is *True*, the nominal pt values are used,
and the jec varied pt values otherwise.
Throws an error if running on data.
:param events: awkward array containing events to process
Resources:
- https://cms-jerc.web.cern.ch/Recommendations/#jet-energy-resolution
""" # noqa
# use local variables for convenience
jet_name = self.jet_name
gen_jet_name = self.gen_jet_name
met_name = self.met_name
# fail when running on data
if self.dataset_inst.is_data:
raise ValueError("attempt to apply jet energy resolution smearing in data")
# save the unsmeared properties in case they are needed later
events = set_ak_column_f32(events, f"{jet_name}.pt_unsmeared", events[jet_name].pt)
events = set_ak_column_f32(events, f"{jet_name}.mass_unsmeared", events[jet_name].mass)
# obtain rho, which might be located at different routes, depending on the nano version
rho = (
events.fixedGridRhoFastjetAll
if "fixedGridRhoFastjetAll" in events.fields else
events.Rho.fixedGridRhoFastjetAll
)
# prepare evaluator variables
variable_map = {
"JetEta": events[jet_name].eta,
"JetPt": events[jet_name].pt,
"Rho": rho,
"systematic": "nom",
}
# helper to run the jer evaluators in normal or b-regression style
if self.bjec_cfg is None:
def run_evaluator(evaluator_name, variable_map):
inputs = [variable_map[inp.name] for inp in self.evaluators[evaluator_name].inputs]
return ak_evaluate(self.evaluators[evaluator_name], *inputs)
else:
bjet_mask = self.bjec_cfg.bjet_selection(events)
def run_evaluator(evaluator_name, variable_map):
# assume same inputs
inputs = [variable_map[inp.name] for inp in self.evaluators[evaluator_name][0].inputs]
return ak.where(
bjet_mask,
ak_evaluate(self.evaluators[evaluator_name][0], *inputs),
ak_evaluate(self.evaluators[evaluator_name][1], *inputs),
)
# extract nominal pt resolution
jer = {"": run_evaluator("jer", variable_map)}
# for simplifications below, use the same values for jer variations
for jer_postfix in self.jer_postfixes:
jer[jer_postfix] = jer[""]
# extract pt resolutions evaluted for jec uncertainties
for jec_postfix in self.jec_postfixes:
_variable_map = variable_map | {"JetPt": events[jet_name][f"pt{jec_postfix}"]}
jer[jec_postfix] = run_evaluator("jer", _variable_map)
# extract scale factors
# uncertainties are extracted with the same evaluator, or a dedicated one as of the new JME format
jersf = {"": run_evaluator("sf", variable_map)}
if self.has_sfunc_evaluator:
sfunc = run_evaluator("sfunc", variable_map)
for jer_postfix in self.jer_postfixes:
sign = 1 if jer_postfix.endswith("_up") else -1
jersf[jer_postfix] = jersf[""] * (1 + sign * sfunc)
else:
for jer_postfix in self.jer_postfixes:
direction = jer_postfix.rsplit("_", 1)[-1]
_variable_map = {**variable_map, "systematic": direction}
jersf[jer_postfix] = run_evaluator("sf", _variable_map)
# extract scale factors for jec uncertainties
for jec_postfix in self.jec_postfixes:
_variable_map = variable_map | {"JetPt": events[jet_name][f"pt{jec_postfix}"]}
jersf[jec_postfix] = run_evaluator("sf", _variable_map)
# jer and jersf keys are now identical to postifxes
assert tuple(self.postfixes) == tuple(jer.keys()) == tuple(jersf.keys())
# array with all JER scale factor variations as an additional axis
# (note: axis needs to be regular for broadcasting to work correctly)
jer = ak_concatenate_safe(
[jer[v][..., None] for v in self.postfixes],
axis=-1,
)
jersf = ak_concatenate_safe(
[jersf[v][..., None] for v in self.postfixes],
axis=-1,
)
# gen jet matching
# mask negative gen jet indices (= no gen match)
gen_jet_idx = events[jet_name][self.gen_jet_idx_column]
valid_gen_jet_idxs = ak.mask(gen_jet_idx, gen_jet_idx >= 0)
# pad list of gen jets to prevent index error on match lookup
max_gen_jet_idx = ak.max(valid_gen_jet_idxs)
padded_gen_jets = ak.pad_none(
events[gen_jet_name],
0 if max_gen_jet_idx is None else (max_gen_jet_idx + 1),
)
# gen jets that match the reconstructed jets
matched_gen_jet = padded_gen_jets[valid_gen_jet_idxs]
# tool based vs. manual implementation
if self.jer_tool:
tool_variable_map = {
"JetPt": events[jet_name].pt,
"JetEta": events[jet_name].eta,
"GenPt": ak.fill_none(matched_gen_jet.pt, -1.0, axis=1),
"Rho": rho,
"EventID": events.event,
"JER": jer,
"JERSF": jersf,
}
smear_factors = self.jer_tool.evaluate(*(tool_variable_map[inp.name] for inp in self.jer_tool.inputs))
# retro actively apply the custom stochastic_smearing_mask
smear_factors = ak.where(
(self.stochastic_smearing_mask(events[jet_name]) & ~ak.is_none(matched_gen_jet)),
smear_factors,
1.0,
)
else:
# -- stochastic smearing
# scale random numbers according to JER SF
jersf2_m1 = jersf**2 - 1
add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
# normally distributed random numbers per jet
random_normal = (
ak_random(0, 1, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
if self.deterministic_seed_index >= 0
else ak_random(0, 1, rand_func=np.random.Generator(
np.random.SFC64(events.event.to_list())).normal,
)
)
# compute smearing factors (stochastic method)
smear_factors_stochastic = ak.where(
self.stochastic_smearing_mask(events[jet_name]),
1.0 + random_normal * jer * add_smear,
1.0,
)
# -- scaling method (using gen match)
# compute the relative (reco - gen) pt difference
if self.gen_jet_matching_nominal:
# use nominal pt for matching
match_pt = events[jet_name].pt
else:
# concatenate varied pt values for broadcasting
n_jer_vars = (1 + 2 * len(self.jer_cfg.uncertainty_regions)) if self.jer_cfg.uncertainty_regions else 3
pt_names = n_jer_vars * ["pt"] + [f"pt{jec_postfix}" for jec_postfix in self.jec_postfixes]
match_pt = ak_concatenate_safe([events[jet_name][pt_name][..., None] for pt_name in pt_names], axis=-1)
pt_relative_diff = 1 - matched_gen_jet.pt / match_pt
# test if matched gen jets are within 3 * resolution
# (no check for dR matching criterion; we assume this was done during nanoAOD production to get the genJetIdx)
is_matched_pt = np.abs(pt_relative_diff) < 3 * jer
is_matched_pt = ak.fill_none(is_matched_pt, False) # masked values = no gen match
# compute smearing factors (scaling method)
smear_factors_scaling = 1.0 + (jersf - 1.0) * pt_relative_diff
# -- hybrid smearing: take smear factors from scaling if there was a match,
# otherwise take the stochastic ones
smear_factors = ak.where(is_matched_pt, smear_factors_scaling, smear_factors_stochastic)
# ensure array is not nullable (avoid ambiguity on Arrow/Parquet conversion)
smear_factors = ak.fill_none(smear_factors, 0.0)
# when uncertainty regions are defined, set smear factors for non-matching jets back to 1
if self.jer_cfg.uncertainty_regions:
regional_smear_factors = []
for i, (region_name, region_func) in enumerate(self.jer_cfg.uncertainty_regions.items()):
region_mask = region_func(events[jet_name])
# apply to both variations
regional_smear_factors.append(ak.where(region_mask, smear_factors[..., 2 * i + 1], 1.0))
regional_smear_factors.append(ak.where(region_mask, smear_factors[..., 2 * i + 2], 1.0))
# concatenate back
smear_factors = ak_concatenate_safe(
[
smear_factors[..., :1], # nominal
*(f[..., None] for f in regional_smear_factors),
smear_factors[..., 1 + len(regional_smear_factors):], # jec variations
],
axis=2,
)
# to allow for code simplifications below, store the reference pt and mass columns upon which the smearing is based
# in the events array for cases where it does not already exist
for jer_postfix in self.jer_postfixes:
events = set_ak_column_f32(events, f"{jet_name}.pt{jer_postfix}", events[jet_name].pt)
events = set_ak_column_f32(events, f"{jet_name}.mass{jer_postfix}", events[jet_name].mass)
# when propagating met, do the same for respective columns
if self.propagate_met:
events = set_ak_column_f32(events, f"{met_name}.pt{jer_postfix}", events[met_name].pt)
events = set_ak_column_f32(events, f"{met_name}.phi{jer_postfix}", events[met_name].phi)
# when propagating met, before smearing is applied, store pt and phi of the full jet system for all variations using
# string postfixes as keys
if self.propagate_met:
jetsum_pt_before = {}
jetsum_phi_before = {}
for postfix in self.postfixes:
jetsum_pt_before[postfix], jetsum_phi_before[postfix] = sum_transverse(
events[jet_name][f"pt{postfix}"],
events[jet_name].phi,
)
# apply the smearing
# (note: this requires that postfixes and smear_factors have the same order, but this should be the case)
for i, postfix in enumerate(self.postfixes):
pt_name = f"pt{postfix}"
m_name = f"mass{postfix}"
events = set_ak_column_f32(events, f"{jet_name}.{pt_name}", events[jet_name][pt_name] * smear_factors[..., i])
events = set_ak_column_f32(events, f"{jet_name}.{m_name}", events[jet_name][m_name] * smear_factors[..., i])
# recover coffea behavior
events = self[attach_coffea_behavior](events, collections=[jet_name], **kwargs)
# met propagation
if self.propagate_met:
# save unsmeared quantities
events = set_ak_column_f32(events, f"{met_name}.pt_unsmeared", events[met_name].pt)
events = set_ak_column_f32(events, f"{met_name}.phi_unsmeared", events[met_name].phi)
# propagate per variation
for postfix in self.postfixes:
# get pt and phi of all jets after correcting
jetsum_pt_after, jetsum_phi_after = sum_transverse(
events[jet_name][f"pt{postfix}"],
events[jet_name].phi,
)
# propagate changes to MET
met_pt, met_phi = propagate_met(
jetsum_pt_before[postfix],
jetsum_phi_before[postfix],
jetsum_pt_after,
jetsum_phi_after,
events[met_name][f"pt{postfix}"],
events[met_name][f"phi{postfix}"],
)
events = set_ak_column_f32(events, f"{met_name}.pt{postfix}", met_pt)
events = set_ak_column_f32(events, f"{met_name}.phi{postfix}", met_phi)
return events
@jer.init
def jer_init(self: Calibrator, **kwargs) -> None:
super(jer, self).init_func(**kwargs)
# load configs
self.jer_cfg = self.get_jer_config()
self.jec_cfg = self.get_jec_config()
self.bjec_cfg = self.jec_cfg.bjec_config
# add jec_cfg for applying nominal smearing to jec variations
jec_sources = self.jec_uncertainty_sources
if jec_sources is None:
jec_sources = self.jec_cfg.uncertainty_sources or []
self.jec_uncertainty_sources = jec_sources
# prepare jec variations
self.jec_postfixes = sum(([f"_jec_{unc}_up", f"_jec_{unc}_down"] for unc in self.jec_uncertainty_sources), [])
jet_jec_columns = {f"{self.jet_name}.{{pt,mass}}{jec_postfix}" for jec_postfix in self.jec_postfixes}
met_jec_columns = {f"{self.met_name}.{{pt,phi}}{jec_postfix}" for jec_postfix in self.jec_postfixes}
# determine gen-level jet index column
lower_first = lambda s: s[0].lower() + s[1:] if s else s
self.gen_jet_idx_column = f"{lower_first(self.gen_jet_name)}Idx"
# register used jet columns
self.uses.add(f"{self.jet_name}.{{pt,eta,phi,mass,{self.gen_jet_idx_column}}}")
self.uses.add(f"{self.gen_jet_name}.{{pt,eta,phi}}")
self.uses.update(jet_jec_columns)
# register used jet columns needed for bjet regression if needed
if self.bjec_cfg is not None:
self.uses.add(f"{self.jet_name}.{{{','.join(self.bjec_cfg.bjet_selection_columns)}}}")
# determine postfixes of jer varied columns based on uncertainty regions
regions = list(self.jer_cfg.uncertainty_regions.keys())
if regions:
self.jer_postfixes = law.util.flatten([f"_jer_{region}_up", f"_jer_{region}_down"] for region in regions)
else:
self.jer_postfixes = ["_jer_up", "_jer_down"]
jer_postfixes_str = ",".join(self.jer_postfixes)
# prepare jer variations and overall postfixes
self.postfixes = [
"",
*self.jer_postfixes,
*self.jec_postfixes,
]
# register produced jet columns
self.produces.add(f"{self.jet_name}.{{pt,mass}}{{,_unsmeared,{jer_postfixes_str}}}")
self.produces.update(jet_jec_columns)
# additional columns when propagating MET
if self.propagate_met:
# register used MET columns
self.uses.add(f"{self.met_name}.{{pt,phi}}")
self.uses.update(met_jec_columns)
# register produced MET columns
self.produces.add(f"{self.met_name}.{{pt,phi}}{{_unsmeared,{jer_postfixes_str}}}")
self.produces.update(met_jec_columns)
if self.jer_cfg.use_jer_tool:
self.uses.add("event")
@jer.requires
def jer_requires(
self: Calibrator,
task: law.Task,
reqs: dict[str, DotDict[str, Any]],
**kwargs,
) -> None:
super(jer, self).requires_func(task=task, reqs=reqs, **kwargs)
if "external_files" in reqs:
return
from columnflow.tasks.external import BundleExternalFiles
reqs["external_files"] = BundleExternalFiles.req(task)
@jer.setup
def jer_setup(
self: Calibrator,
task: law.Task,
reqs: dict[str, DotDict[str, Any]],
inputs: dict[str, Any],
reader_targets: law.util.InsertableDict,
**kwargs,
) -> None:
"""
Load the correct jer files using the :py:func:`from_string` method of the
:external+correctionlib:py:class:`correctionlib.highlevel.CorrectionSet` function and apply the corrections as
needed.
The source files for the :external+correctionlib:py:class:`correctionlib.highlevel.CorrectionSet` instance are
extracted with the :py:meth:`~.jer.get_jer_file`.
Uses the member function :py:meth:`~.jer.get_jer_config` to construct the required keys, which are based on the
following information about the JER:
- campaign
- version
- jet_type
A corresponding example snippet within the *config_inst* could like something like this:
.. code-block:: python
cfg.x.jer = DotDict.wrap({
"Jet": {
"campaign": f"Summer19UL{year2}{jerc_postfix}",
"version": "JRV3",
"jet_type": "AK4PFchs",
},
})
:param reqs: Requirement dictionary for this :py:class:`~columnflow.calibration.Calibrator` instance.
:param inputs: Additional inputs, currently not used.
:param reader_targets: TODO: add documentation.
"""
super(jer, self).setup_func(task=task, reqs=reqs, inputs=inputs, reader_targets=reader_targets, **kwargs)
# compute JER keys from config information
if self.bjec_cfg is None:
jer_keys = {
"jer": f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_PtResolution_{self.jer_cfg.jet_type}",
"sf": f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_ScaleFactor_{self.jer_cfg.jet_type}",
"sfunc": f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_SFUncertainty_{self.jer_cfg.jet_type}",
}
else:
# group evaluators in pairs (tagged, untagged)
jer_keys = {
"jer": (
f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_PtResolution_{self.bjec_cfg.jet_types[0]}",
f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_PtResolution_{self.bjec_cfg.jet_types[1]}",
),
"sf": (
f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_ScaleFactor_{self.bjec_cfg.jet_types[0]}",
f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_ScaleFactor_{self.bjec_cfg.jet_types[1]}",
),
"sfunc": (
f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_SFUncertainty_{self.bjec_cfg.jet_types[0]}",
f"{self.jer_cfg.campaign}_{self.jer_cfg.version}_MC_SFUncertainty_{self.bjec_cfg.jet_types[1]}",
),
}
# import the correction sets from the external file
jer_file = self.get_jer_file(reqs["external_files"].files)
correction_set = load_correction_set(jer_file)
# check if the correction set has the updated format with "*_SFUncertainty_*" evaluators
self.has_sfunc_evaluator = law.util.make_tuple(jer_keys["sfunc"])[0] in set(correction_set.keys())
if not self.has_sfunc_evaluator:
del jer_keys["sfunc"]
# store the evaluators
self.evaluators = {
name: get_evaluators(correction_set, [key])[0]
for name, key in jer_keys.items()
}
# check if the jer smearing tool should be used, and optionally set it up
self.jer_tool = None
if self.jer_cfg.use_jer_tool:
jer_tool_file = self.get_jer_tool_file(reqs["external_files"].files)
self.jer_tool = load_correction_set(jer_tool_file)["JERSmear"]
# use deterministic seeds for random smearing if requested
if self.deterministic_seed_index >= 0:
# deterministic seeds and the jer smearing tool should not be used simultaneously
if self.jer_tool is not None:
raise ValueError(
"deterministic seeds for random smearing and the jer smearing tool should not be used simultaneously",
)
idx = self.deterministic_seed_index
bit_generator = np.random.SFC64
def deterministic_normal(loc, scale, seed):
return np.asarray([
np.random.Generator(bit_generator(_seed)).normal(_loc, _scale, size=idx + 1)[-1]
for _loc, _scale, _seed in zip(loc, scale, seed)
])
self.deterministic_normal = deterministic_normal
jer_horn_handling = jer.derive("jer_horn_handling", cls_dict={
# source: https://cms-jerc.web.cern.ch/Recommendations/#note-25eta30
"stochastic_smearing_mask": lambda self, jets: (abs(jets.eta) < 2.5) | (abs(jets.eta) > 3.0),
})
# explicit calibrators for standard jet collections
jer_ak4 = jer.derive("jer_ak4", cls_dict={"jet_name": "Jet", "gen_jet_name": "GenJet"})
jer_ak8 = jer.derive("jer_ak8", cls_dict={"jet_name": "FatJet", "gen_jet_name": "GenJetAK8", "propagate_met": False})
#
# single calibrator for doing both JEC and JER smearing
#
[docs]
@calibrator(
# name of the jet collection to smear
jet_name="Jet",
# name of the associated gen jet collection (for JER smearing)
gen_jet_name="GenJet",
# toggle for propagation to MET
propagate_met=None,
# functions to determine configs and files
get_jec_file=None,
get_jec_config=None,
get_jer_file=None,
get_jer_config=None,
)
def jets(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
"""
Instance of :py:class:`~columnflow.calibration.Calibrator` that does all relevant calibrations for jets, i.e. JEC
and JER. For more information, see :py:func:`~.jec` and :py:func:`~.jer`.
:param events: awkward array containing events to process
"""
# apply jet energy corrections
events = self[self.jec_cls](events, **kwargs)
# apply jer smearing on MC only
if self.dataset_inst.is_mc:
events = self[self.jer_cls](events, **kwargs)
return events
@jets.init
def jets_init(self: Calibrator, **kwargs) -> None:
super(jets, self).init_func(**kwargs)
# create custom jec and jer calibrators, using the jet name as the identifying value
def get_attrs(attrs):
cls_dict = {}
for attr in attrs:
if (value := getattr(self, attr, UNSET)) is not UNSET:
cls_dict[attr] = value
return cls_dict
jec_attrs = ["jet_name", "gen_jet_name", "propagate_met", "get_jec_file", "get_jec_config"]
self.jec_cls = jec.derive(f"jec_{self.jet_name}", cls_dict=get_attrs(jec_attrs))
self.uses.add(self.jec_cls)
self.produces.add(self.jec_cls)
if self.dataset_inst.is_mc:
jer_attrs = ["jet_name", "gen_jet_name", "propagate_met", "get_jer_file", "get_jer_config"]
self.jer_cls = jer.derive(f"jer_{self.jet_name}", cls_dict=get_attrs(jer_attrs))
self.uses.add(self.jer_cls)
self.produces.add(self.jer_cls)
# explicit calibrators for standard jet collections
jets_ak4 = jets.derive("jets_ak4", cls_dict={"jet_name": "Jet", "gen_jet_name": "GenJet"})
jets_ak8 = jets.derive("jets_ak8", cls_dict={"jet_name": "FatJet", "gen_jet_name": "GenJetAK8"})