Repository: columnflow/columnflow
Pull request #665: Fix to jer application on jec variations
Author: login: JulesVandenbroeck
Labels: name: priority-high
description: High priority stuff
name: fix
description: Fixes a bug
name: CMS
description: CMS background (for bookkeeping)
Created: 2025-04-02T09:42:05Z | Merged: 2025-04-28T13:01:13Z

This pull request intends to fix Jet energy resolution smearing. The issue is that in current implementation the jet energy resolution smearing is applied only to the nominal `Jet.pt` and `Jet.mass` without looking at Jet Energy Correction (JEC) uncertainty variations. The same smearing needs to be also applied to the columns `Jet.{pt,mass}_jec_{jec_uncertainty_name}_{shift}` for each JEC uncertainty variation. In addition the smearing needs to be propagate to MET if `self.propagate_met=True` for each JEC uncertainty variation. This pull request fixes these issues by 're-producing' these columns with the smearing factor applied and the same for MET if requested.

**Checks**
- ~~Currently all JEC uncertainty variations are calculated in the jec producer instead of only the uncertainties in `self.uncertainty_sources`. Should this be changed for efficiency?~~ (edit: I misread the definition of `self.evaluators`)
- Is this implemenation bug-proof?
- ~~Should the possibility for a shift that includes both JEC and JER  uncertainty variations be included?~~


Diff:
diff --git a/columnflow/calibration/cms/jets.py b/columnflow/calibration/cms/jets.py
index bd910264b..e7eb6b330 100644
--- a/columnflow/calibration/cms/jets.py
+++ b/columnflow/calibration/cms/jets.py
@@ -10,7 +10,7 @@
 
 from columnflow.types import Any
 from columnflow.calibration import Calibrator, calibrator
-from columnflow.calibration.util import ak_random, propagate_met
+from columnflow.calibration.util import ak_random, propagate_met, sum_transverse
 from columnflow.production.util import attach_coffea_behavior
 from columnflow.util import maybe_import, InsertableDict, DotDict
 from columnflow.columnar_util import set_ak_column, layout_ak_array, optional_column as optional
@@ -134,7 +134,7 @@ def get_jerc_file_default(self: Calibrator, external_files: DotDict) -> str:
 
     :param external_files: Dictionary containing the information about the file location
     :return: path or url to correction file(s)
-    """ # noqa
+    """  # noqa
 
     # get config
     try_attrs = ("get_jec_config", "get_jer_config")
@@ -320,7 +320,7 @@ def jec(
         values to the missing transverse energy (MET) using
         :py:func:`~columnflow.calibration.util.propagate_met` for events where
         ``met.eta > *min_eta_met_prop*``.
-    """ # noqa
+    """  # noqa
     # use local variable for convenience
     jet_name = self.jet_name
 
@@ -481,7 +481,8 @@ def jec_init(self: Calibrator) -> None:
 
     sources = self.uncertainty_sources
     if sources is None:
-        sources = jec_cfg.uncertainty_sources
+        sources = 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}}")
@@ -597,20 +598,15 @@ def make_jme_keys(names, jec=jec_cfg, is_data=self.dataset_inst.is_data):
             for name in names
         ]
 
-    # take sources from constructor or config
-    sources = self.uncertainty_sources
-    if sources is None:
-        sources = jec_cfg.uncertainty_sources
-
     jec_keys = make_jme_keys(jec_cfg.levels)
     jec_keys_subset_type1_met = make_jme_keys(jec_cfg.levels_for_type1_met)
-    junc_keys = make_jme_keys(sources, is_data=False)  # uncertainties only stored as MC keys
+    junc_keys = make_jme_keys(self.uncertainty_sources, is_data=False)  # uncertainties only stored as MC keys
 
     # store the evaluators
     self.evaluators = {
         "jec": get_evaluators(correction_set, jec_keys),
         "jec_subset_type1_met": get_evaluators(correction_set, jec_keys_subset_type1_met),
-        "junc": dict(zip(sources, get_evaluators(correction_set, junc_keys))),
+        "junc": dict(zip(self.uncertainty_sources, get_evaluators(correction_set, junc_keys))),
     }
 
 
@@ -698,6 +694,12 @@ def get_jer_config_default(self: Calibrator) -> DotDict:
     get_jer_file=get_jerc_file_default,
     # 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,
+    # jec uncertainty sources to propagate jer to, defaults to config when empty
+    jec_uncertainty_sources=None,
+    # whether gen jet matching should be performed relative to the nominal jet pt, or the jec varied values
+    gen_jet_matching_nominal=False,
 )
 def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
     """
@@ -742,22 +744,41 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """ # noqa
+    """  # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = self.jer_variations
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    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,
+        )
+    )
+
     # obtain rho, which might be located at different routes, depending on the nano version
     rho = (
         events.fixedGridRhoFastjetAll
@@ -765,50 +786,60 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
         events.Rho.fixedGridRhoFastjetAll
     )
 
-    # variable naming convention
+    # prepare evaluator variables
     variable_map = {
         "JetEta": events[jet_name].eta,
         "JetPt": events[jet_name].pt,
         "Rho": rho,
+        "systematic": jer_nom,
     }
 
-    # pt resolution
+    # extract nominal pt resolution
     inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-    jer = ak_evaluate(self.evaluators["jer"], *inputs)
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
 
-    # JER scale factors and systematic variations
+    # for simplifications below, use the same values for jer variations
+    jerpt[jer_up] = jerpt[jer_nom]
+    jerpt[jer_down] = jerpt[jer_nom]
+
+    # extract pt resolutions evaluted for jec uncertainties
+    for jec_var in self.jec_variations:
+        _variable_map = variable_map | {"JetPt": events[jet_name][f"pt_{jec_var}"]}
+        inputs = [_variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+        jerpt[jec_var] = ak_evaluate(self.evaluators["jer"], *inputs)
+
+    # extract scale factors
     jersf = {}
-    for syst in ("nom", "up", "down"):
-        variable_map_syst = dict(variable_map, systematic=syst)
-        inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-        jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
+    for jer_var in self.jer_variations:
+        _variable_map = variable_map | {"systematic": jer_var}
+        inputs = [_variable_map[inp.name] for inp in self.evaluators["sf"].inputs]
+        jersf[jer_var] = ak_evaluate(self.evaluators["sf"], *inputs)
+
+    # extract scale factors for jec uncertainties
+    for jec_var in self.jec_variations:
+        _variable_map = variable_map | {"JetPt": events[jet_name][f"pt_{jec_var}"]}
+        inputs = [_variable_map[inp.name] for inp in self.evaluators["sf"].inputs]
+        jersf[jec_var] = ak_evaluate(self.evaluators["sf"], *inputs)
 
     # array with all JER scale factor variations as an additional axis
     # (note: axis needs to be regular for broadcasting to work correctly)
+    jerpt = ak.concatenate(
+        [jerpt[v][..., None] for v in self.jer_variations + self.jec_variations],
+        axis=-1,
+    )
     jersf = ak.concatenate(
-        [jersf[syst][..., None] for syst in ("nom", "up", "down")],
+        [jersf[v][..., None] for v in self.jer_variations + self.jec_variations],
         axis=-1,
     )
 
     # -- stochastic smearing
-    # normally distributed random numbers according to JER
-    jer_random_normal = (
-        ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-        if self.deterministic_seed_index >= 0
-        else ak_random(0, jer, rand_func=np.random.Generator(
-            np.random.SFC64(events.event.to_list())).normal,
-        )
-    )
 
     # scale random numbers according to JER SF
-    jersf2_m1 = jersf ** 2 - 1
+    jersf2_m1 = jersf**2 - 1
     add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
 
-    # broadcast over JER SF variations
-    jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
     # compute smearing factors (stochastic method)
-    smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
+    smear_factors_stochastic = 1.0 + random_normal * jerpt * add_smear
 
     # -- scaling method (using gen match)
 
@@ -824,124 +855,139 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
     )
 
     # gen jets that match the reconstructed jets
-    matched_gen_jets = padded_gen_jets[valid_gen_jet_idxs]
+    matched_gen_jet = padded_gen_jets[valid_gen_jet_idxs]
 
     # compute the relative (reco - gen) pt difference
-    pt_relative_diff = (events[jet_name].pt - matched_gen_jets.pt) / events[jet_name].pt
+    if self.gen_jet_matching_nominal:
+        # use nominal pt for matching
+        match_pt = events[jet_name].pt
+    else:
+        # concatenate varied pt values for broadcasting
+        pt_names = ["pt" for _ in self.jer_variations] + [f"pt_{jec_var}" for jec_var in self.jec_variations]
+        match_pt = ak.concatenate([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
-    is_matched_pt = np.abs(pt_relative_diff) < 3 * jer
+    # (no check for Delta-R matching criterion; we assume this was done during nanoAOD production to get the genJetIdx)
+    is_matched_pt = np.abs(pt_relative_diff) < 3 * jerpt
     is_matched_pt = ak.fill_none(is_matched_pt, False)  # masked values = no gen match
 
-    # (no check for Delta-R matching criterion; we assume this was done during
-    # nanoAOD production to get the `genJetIdx`)
-
-    # broadcast over JER SF variations
-    pt_relative_diff, jersf = ak.broadcast_arrays(pt_relative_diff, jersf)
-
     # 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[:, :, None],
-        smear_factors_scaling,
-        smear_factors_stochastic,
-    )
+    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)
 
-    # store pt and phi of the full jet system
+    # to allow for code simplifications below, store the reference pt and mass columns upon which the smearing is based
+    # into the events array for cases where it shouldn't already exist
+    for direction in ["up", "down"]:
+        events = set_ak_column_f32(events, f"{jet_name}.pt_jer_{direction}", events[jet_name].pt)
+        events = set_ak_column_f32(events, f"{jet_name}.mass_jer_{direction}", 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_{direction}", events[met_name].pt)
+            events = set_ak_column_f32(events, f"{met_name}.phi_jer_{direction}", 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 = events[jet_name].sum(axis=1)
-        jetsum_pt_before = jetsum.pt
-        jetsum_phi_before = jetsum.phi
-
-    # apply the smearing factors to the pt and mass
-    # (note: apply variations first since they refer to the original pt)
-    events = set_ak_column_f32(events, f"{jet_name}.pt_jer_up", events[jet_name].pt * smear_factors[:, :, 1])
-    events = set_ak_column_f32(events, f"{jet_name}.mass_jer_up", events[jet_name].mass * smear_factors[:, :, 1])
-    events = set_ak_column_f32(events, f"{jet_name}.pt_jer_down", events[jet_name].pt * smear_factors[:, :, 2])
-    events = set_ak_column_f32(events, f"{jet_name}.mass_jer_down", events[jet_name].mass * smear_factors[:, :, 2])
-    events = set_ak_column_f32(events, f"{jet_name}.pt", events[jet_name].pt * smear_factors[:, :, 0])
-    events = set_ak_column_f32(events, f"{jet_name}.mass", events[jet_name].mass * smear_factors[:, :, 0])
+        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"{self.met_name}.pt_unsmeared", events[self.met_name].pt)
-        events = set_ak_column_f32(events, f"{self.met_name}.phi_unsmeared", events[self.met_name].phi)
-
-        # get pt and phi of all jets after correcting
-        jetsum = events[jet_name].sum(axis=1)
-        jetsum_pt_after = jetsum.pt
-        jetsum_phi_after = jetsum.phi
-
-        # propagate changes to MET
-        met_pt, met_phi = propagate_met(
-            jetsum_pt_before,
-            jetsum_phi_before,
-            jetsum_pt_after,
-            jetsum_phi_after,
-            events[self.met_name].pt,
-            events[self.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)
+        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,
+            )
 
-        # syst variations on top of corrected MET
-        met_pt_up, met_phi_up = propagate_met(
-            jetsum_pt_after,
-            jetsum_phi_after,
-            events[jet_name].pt_jer_up,
-            events[jet_name].phi,
-            met_pt,
-            met_phi,
-        )
-        met_pt_down, met_phi_down = propagate_met(
-            jetsum_pt_after,
-            jetsum_phi_after,
-            events[jet_name].pt_jer_down,
-            events[jet_name].phi,
-            met_pt,
-            met_phi,
-        )
-        events = set_ak_column_f32(events, f"{self.met_name}.pt_jer_up", met_pt_up)
-        events = set_ak_column_f32(events, f"{self.met_name}.pt_jer_down", met_pt_down)
-        events = set_ak_column_f32(events, f"{self.met_name}.phi_jer_up", met_phi_up)
-        events = set_ak_column_f32(events, f"{self.met_name}.phi_jer_down", met_phi_down)
+            # 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) -> None:
+    # add jec_cfg for applying nominal smearing to jec variations
+    jec_cfg = self.get_jec_config()
+    jec_sources = self.jec_uncertainty_sources
+    if jec_sources is None:
+        jec_sources = jec_cfg.uncertainty_sources or []
+        self.jec_uncertainty_sources = jec_sources
+
+    # prepare jec variations
+    self.jec_variations = 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_source}" for jec_source in self.jec_variations}
+    met_jec_columns = {f"{self.met_name}.{{pt,phi}}_{jec_source}" for jec_source in self.jec_variations}
+
     # determine gen-level jet index column
     lower_first = lambda s: s[0].lower() + s[1:] if s else s
     self.gen_jet_idx_column = lower_first(self.gen_jet_name) + "Idx"
 
+    # prepare jer variations and postfixes
+    self.jer_variations = ["nom", "up", "down"]
+    self.postfixes = ["", "_jer_up", "_jer_down"] + [f"_{jec_var}" for jec_var in self.jec_variations]
+
     # register used jet columns
     self.uses.add(f"{self.jet_name}.{{pt,eta,phi,mass,{self.gen_jet_idx_column}}}")
-
-    # register used gen jet columns
     self.uses.add(f"{self.gen_jet_name}.{{pt,eta,phi}}")
+    if jec_sources:
+        self.uses |= jet_jec_columns
 
     # register produced jet columns
     self.produces.add(f"{self.jet_name}.{{pt,mass}}{{,_unsmeared,_jer_up,_jer_down}}")
+    if jec_sources:
+        self.produces |= jet_jec_columns
 
-    # register produced MET columns
+    # additional columns when propagating MET
     if self.propagate_met:
         # register used MET columns
         self.uses.add(f"{self.met_name}.{{pt,phi}}")
+        if jec_sources:
+            self.uses |= met_jec_columns
 
         # register produced MET columns
         self.produces.add(f"{self.met_name}.{{pt,phi}}{{,_jer_up,_jer_down,_unsmeared}}")
+        if jec_sources:
+            self.produces |= met_jec_columns
 
 
 @jer.requires
diff --git a/columnflow/calibration/util.py b/columnflow/calibration/util.py
index ac20de9bb..b95e0b20a 100644
--- a/columnflow/calibration/util.py
+++ b/columnflow/calibration/util.py
@@ -39,6 +39,24 @@ def ak_random(*args, rand_func: Callable) -> ak.Array:
     return ak.from_numpy(np_randvals)
 
 
+def sum_transverse(pt: ak.Array, phi: ak.Array) -> tuple[ak.Array, ak.Array]:
+    """
+    Helper function to compute the sum of transverse vectors given their pt and phi values.
+
+    :param pt: Transverse momentum of the vector(s).
+    :param phi: Azimuthal angle of the vector(s).
+    :return: Tuple containing the transverse momentum and azimuthal angle of the sum of the vectors.
+    """
+    px_sum = ak.sum(pt * np.cos(phi), axis=-1)
+    py_sum = ak.sum(pt * np.sin(phi), axis=-1)
+
+    # compute new components
+    pt_sum = (px_sum**2.0 + py_sum**2.0)**0.5
+    phi_sum = np.arctan2(py_sum, px_sum)
+
+    return pt_sum, phi_sum
+
+
 def propagate_met(
     jet_pt1: (ak.Array),
     jet_phi1: ak.Array,


Reviews:
user: login: dsavoiu
body: Dear @JulesVandenbroeck, thanks a lot for looking into this and providing a fix! And also to @pkausw and @riga for their input.

You are right of course that the nominal JER smearing should be applied not only to the nominal jet pT but also to the uncertainty-shifted pTs, which seems to have been forgotten when implementing the `jer` producer.

The implementation proposed as part of this PR looks good to me overall, but I do have one concern regarding an apparent in-place array modification which could lead to wrong results (see detailed comments above). Would you be able to run a cross-check for this against an implementation not using the event array for storing the shifted columns @JulesVandenbroeck?
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-07T14:58:11Z
user: login: riga
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-10T13:27:01Z
user: login: JulesVandenbroeck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T09:28:29Z
user: login: dsavoiu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T11:53:13Z
user: login: riga
body: This looks good to me now in total.

@dsavoiu With the additional changes, I think a third pair of eyes would be helpful. Would you have the time to give it another pass? (also regarding the two matching strategies and the default). Thank you! :+1:
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T09:06:41Z
user: login: dsavoiu
body: Thanks a lot @riga for refining the implementation! I think generating one set of RNGs and then using them consistently for smearing all JEC-varied jet pTs is a good approach and avoids adding extra runtime when using deterministic seeds.

I have some small suggestions, mostly regarding code readability, but nothing major. It would be great if you could address them, but otherwise I think this can be merged.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T17:11:47Z
user: login: JulesVandenbroeck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T10:05:15Z
user: login: JulesVandenbroeck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T10:18:26Z
user: login: JulesVandenbroeck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T10:22:38Z
user: login: JulesVandenbroeck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T10:25:42Z
user: login: mafrahm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T11:04:10Z
user: login: riga
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T12:04:59Z
user: login: riga
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T12:06:29Z
user: login: riga
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T12:15:21Z
user: login: riga
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T12:15:54Z
user: login: dsavoiu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T13:14:09Z

Review comments:
diff_hunk: @@ -868,6 +876,22 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
     events = set_ak_column_f32(events, f"{jet_name}.pt", events[jet_name].pt * smear_factors[:, :, 0])
     events = set_ak_column_f32(events, f"{jet_name}.mass", events[jet_name].mass * smear_factors[:, :, 0])
 
+    jetsum_before_jec_uncertainty = {}
+    for name in self.jec_uncertainty_sources:
+        for junc_dir in ("up", "down"):
+            # store pt and phi of the full jet system for each jec uncertainty to propagate met
+            if self.propagate_met:
+                jets = events[jet_name]
+                jets = set_ak_column_f32(jets, "pt", jets[f"pt_jec_{name}_{junc_dir}"])
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Does this create a copy, or does it replace `pt` in the original event array. If the latter then there is a danger that this could produce wrong results. Maybe we could ensure that a copy is made, or better still use a local temporary `dict` to store the shifted `pt` and `mass` columns?
created_at: 2025-04-07T14:47:32Z
author_association: MEMBER
diff_hunk: @@ -917,6 +941,30 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
         events = set_ak_column_f32(events, f"{self.met_name}.phi_jer_up", met_phi_up)
         events = set_ak_column_f32(events, f"{self.met_name}.phi_jer_down", met_phi_down)
 
+        for name in self.jec_uncertainty_sources:
+            for junc_dir in ("up", "down"):
+
+                jets = events[jet_name]
+                jets = set_ak_column_f32(jets, "pt", jets[f"pt_jec_{name}_{junc_dir}"])
+                jets = set_ak_column_f32(jets, "mass", jets[f"mass_jec_{name}_{junc_dir}"])
+                jetsum = jets.sum(axis=1)
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Same as above, it would be better to make a explicit copy here or use local variables.
created_at: 2025-04-07T14:48:45Z
author_association: MEMBER
diff_hunk: @@ -943,6 +991,31 @@ def jer_init(self: Calibrator) -> None:
         # register produced MET columns
         self.produces.add(f"{self.met_name}.{{pt,phi}}{{,_jer_up,_jer_down,_unsmeared}}")
 
+    # add jec_cfg for applying smearing to jec variations
+    jec_cfg = self.get_jec_config()
+    sources = self.jec_uncertainty_sources
+    if sources is None:
+        sources = jec_cfg.uncertainty_sources
+        self.jec_uncertainty_sources = sources
+
+    self.produces |= {
path: columnflow/calibration/cms/jets.py
user: login: riga
body: For consistency, the additional jec-specific columns should also be in `uses`. 
created_at: 2025-04-10T13:26:52Z
author_association: MEMBER
diff_hunk: @@ -868,6 +876,22 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
     events = set_ak_column_f32(events, f"{jet_name}.pt", events[jet_name].pt * smear_factors[:, :, 0])
     events = set_ak_column_f32(events, f"{jet_name}.mass", events[jet_name].mass * smear_factors[:, :, 0])
 
+    jetsum_before_jec_uncertainty = {}
+    for name in self.jec_uncertainty_sources:
+        for junc_dir in ("up", "down"):
+            # store pt and phi of the full jet system for each jec uncertainty to propagate met
+            if self.propagate_met:
+                jets = events[jet_name]
+                jets = set_ak_column_f32(jets, "pt", jets[f"pt_jec_{name}_{junc_dir}"])
path: columnflow/calibration/cms/jets.py
user: login: JulesVandenbroeck
body: Sorry for the late response! I double-checked and `jets = events[jet_name` does create a copy of the original event array. To ensure this is always the case it now takes a deep copy of the original event array.
created_at: 2025-04-14T09:28:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -868,6 +876,22 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
     events = set_ak_column_f32(events, f"{jet_name}.pt", events[jet_name].pt * smear_factors[:, :, 0])
     events = set_ak_column_f32(events, f"{jet_name}.mass", events[jet_name].mass * smear_factors[:, :, 0])
 
+    jetsum_before_jec_uncertainty = {}
+    for name in self.jec_uncertainty_sources:
+        for junc_dir in ("up", "down"):
+            # store pt and phi of the full jet system for each jec uncertainty to propagate met
+            if self.propagate_met:
+                jets = events[jet_name]
+                jets = set_ak_column_f32(jets, "pt", jets[f"pt_jec_{name}_{junc_dir}"])
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Hi @JulesVandenbroeck, thanks for looking into this!

Looks like there may have been some confusion here, which I maybe caused by using the word "copy" a bit too loosely. To clarify, you are correct that the `jets` array returned by the `__getitem__`/`[]` call is a different object than the original array, but it is actually a view of the same underlying data and not a full copy (copying would take too long and make this very inefficient for large arrays). What I meant was whether setting the `pt` field of this object interferes with the original `events` array, as I didn't remember how we implemented it in `set_ak_column`. Luckily, this seems not to be the case, and the `pt` field is just replaced with a different array in the `jets` object, but not in the underlying `events` array.

So it's all fine. And, in fact, adding `ak.copy` here would be counterproductive because it would actually copy the underlying data unnecessarily. Bearing this in mind, let's actually return to the way you had it previously, i.e. without the `ak.copy`. Sorry for the confusion!
created_at: 2025-04-14T11:53:13Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: At the moment this assertion seems to be true by construction, since the `jec_variations` always contain a `"_"` character whereas the `jer_variations` do not. However, it's a good idea in principle to check for collisions here, since the JEC and JER variation names are used as keys in the same dictionary. Still, maybe a comment or error message string can be added to document the assertion purpose?
created_at: 2025-04-25T15:38:01Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
+
+    # for simplifications below, use the same values for jer variations
+    jerpt[jer_up] = jerpt[jer_nom]
+    jerpt[jer_down] = jerpt[jer_nom]
+
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: It would appear that these immediately get overwritten in the loop over `jec_variations`. Any reason for setting them beforehand or can these lines be omitted?
created_at: 2025-04-25T15:53:45Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
+
+    # for simplifications below, use the same values for jer variations
+    jerpt[jer_up] = jerpt[jer_nom]
+    jerpt[jer_down] = jerpt[jer_nom]
+
+    # extract pt resolutions evaluted for jec uncertainties
+    for jec_var in jec_variations:
+        _variable_map = variable_map | {"JetPt": events[jet_name][f"pt_jec_{jec_var}"]}
+        inputs = [_variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+        jerpt[jec_var] = ak_evaluate(self.evaluators["jer"], *inputs)
+
+    # extract scale factors
+    jersf = {}
+    for jer_var in jer_variations:
+        _variable_map = variable_map | {"systematic": jer_var}
+        inputs = [_variable_map[inp.name] for inp in self.evaluators["sf"].inputs]
+        jersf[jer_var] = ak_evaluate(self.evaluators["sf"], *inputs)
+
+    # extract scale factors for jec uncertainties
+    for jec_var in jec_variations:
+        _variable_map = variable_map | {"JetPt": events[jet_name][f"pt_jec_{jec_var}"]}
+        inputs = [_variable_map[inp.name] for inp in self.evaluators["sf"].inputs]
+        jersf[jec_var] = ak_evaluate(self.evaluators["sf"], *inputs)
+
+    # array with all JER scale factor variations as an additional axis
+    # (note: axis needs to be regular for broadcasting to work correctly)
+    jerpt = ak.concatenate(
+        [jerpt[v][..., None] for v in jer_variations + jec_variations],
+        axis=-1,
+    )
+    jersf = ak.concatenate(
+        [jersf[v][..., None] for v in jer_variations + jec_variations],
+        axis=-1,
+    )
 
-        # test if matched gen jets are within 3 * resolution
-        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
+    # -- stochastic smearing
 
-        # (no check for Delta-R matching criterion; we assume this was done during
-        # nanoAOD production to get the `genJetIdx`)
+    # scale random numbers according to JER SF
+    jersf2_m1 = jersf**2 - 1
+    add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
 
-        # broadcast over JER SF variations
-        pt_relative_diff, jersf = ak.broadcast_arrays(pt_relative_diff, jersf)
+    # compute smearing factors (stochastic method)
+    smear_factors_stochastic = 1.0 + jer_random_normal * jerpt * add_smear
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Was briefly confused by this since in the old implementation the `jer_random_normal` already contained the JER, so it seemed like this part was applying the smearing twice. Renaming the random variable to `random_normal` or similar would improve the clarity here.
```suggestion
    smear_factors_stochastic = 1.0 + random_normal * jerpt * add_smear
```


created_at: 2025-04-25T16:03:22Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_random_normal = (
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Since this no longer depends on the JER, I would suggest to rename this variable to just `random_normal` (see comment further down).
created_at: 2025-04-25T16:29:24Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Very minor, but is there a reason to use `jerpt` vs just `jer` as before? Reading this I keep thinking that this variable stores some variation of the transverse momentum rather than the JER itself.
created_at: 2025-04-25T16:37:24Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Shouldn't the calculation of `jec_variations`, `jer_variations` and `postfixes` be moved to `jer_init`? They don't depend on the event content so the calculation only needs to happen once and they could be stored as instance variables for use in the main function body.

An added benefit of that approach would be that these variables could directly be used in `jer_init` for the computation of the `uses` and `produces` column names.
created_at: 2025-04-25T16:53:41Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
path: columnflow/calibration/cms/jets.py
user: login: JulesVandenbroeck
body: This is a good point. Moving `jec_variations`, `jer_variations` and `postfixed` to `jer_init` makes sense. Using these in `uses` and `produces` makes less sence as it is less transparent. 

Since `jer` also produces `"{pt,mass}_unsmeared"` you would need to join the list `postfixes` with `,` instead of a simpleloop: `self.produces.add(f"{self.jet_name}.{{pt,mass}}{{_unsmeared, ','.join(self.postfixes)}}")`. This seems less transparent then what is currently implemented. For `uses` one needs only the nominal and jec variations so `self.postfixes` can not be used
created_at: 2025-04-28T10:05:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
+
+    # for simplifications below, use the same values for jer variations
+    jerpt[jer_up] = jerpt[jer_nom]
+    jerpt[jer_down] = jerpt[jer_nom]
+
path: columnflow/calibration/cms/jets.py
user: login: JulesVandenbroeck
body: This is not the case if I am understanding this correctly. `jerpt` contains the `pt` of the jets for each variation (both jer- and jec-variations) that is used in correctionlib. For jer-variations (`{pt,mass}_jer_{up,down}`) this is still the nominal jet pt. The loop over `jec_variations` writes only the jec-variations as keys in `jerpt`, not any variation in `jer_variations`.
created_at: 2025-04-28T10:18:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
path: columnflow/calibration/cms/jets.py
user: login: JulesVandenbroeck
body: Thank you for noticing this. I believe indeed that this assertion is by construction never triggered because. I also included the `jec_` prefix in `jec_variations` in the latest commit so these lists are no longer unique based on just a `_` character (if `unc` is an empty string). Therefore I removed the assertion in this commit
created_at: 2025-04-28T10:22:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -954,13 +951,21 @@ def jer_init(self: Calibrator) -> None:
     if jec_sources is None:
         jec_sources = jec_cfg.uncertainty_sources or []
         self.jec_uncertainty_sources = jec_sources
-        jet_jec_columns = {f"{self.jet_name}.{{pt,mass}}_jec_{jec_source}_{{up,down}}" for jec_source in jec_sources}
-        met_jec_columns = {f"{self.met_name}.{{pt,phi}}_jec_{jec_source}_{{up,down}}" for jec_source in jec_sources}
+
+    # prepare jec variations
+    self.jec_variations = 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_source}" for jec_source in self.jec_variations}
+    met_jec_columns = {f"{self.met_name}.{{pt,phi}}_{jec_source}" for jec_source in self.jec_variations}
path: columnflow/calibration/cms/jets.py
user: login: JulesVandenbroeck
body: the preparation of jec variations and the sets of columns used in registering the `uses` and `produces` needs to be done even if `jec_sources` is not None.
created_at: 2025-04-28T10:25:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
path: columnflow/calibration/cms/jets.py
user: login: mafrahm
body: I'd recommend to move them either to the `post_init` or the `setup` function as long as these computations do not change anything in the task dependency tree
created_at: 2025-04-28T11:04:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
+
+    # for simplifications below, use the same values for jer variations
+    jerpt[jer_up] = jerpt[jer_nom]
+    jerpt[jer_down] = jerpt[jer_nom]
+
path: columnflow/calibration/cms/jets.py
user: login: riga
body: I just checked this again :+1:
created_at: 2025-04-28T12:04:59Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
path: columnflow/calibration/cms/jets.py
user: login: riga
body: My bad, I renamed this somewhat too rashly.
created_at: 2025-04-28T12:06:29Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
path: columnflow/calibration/cms/jets.py
user: login: riga
body: I agree that `post_init` should be a good place for this. Since this is targeting master though (as a critical bug fix before we sunset this to v0.2), `init` is the only option. I will port this later today and move this to `post_init`.
created_at: 2025-04-28T12:15:21Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
path: columnflow/calibration/cms/jets.py
user: login: riga
body: I will rename this in the port the v0.3 later today.
created_at: 2025-04-28T12:15:54Z
author_association: MEMBER
diff_hunk: @@ -747,294 +744,245 @@ def jer(self: Calibrator, events: ak.Array, **kwargs) -> ak.Array:
 
     *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
-    """  # noqa
+    """ # 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")
 
+    # prepare variations
+    jer_nom, jer_up, jer_down = jer_variations = ["nom", "up", "down"]
+    jec_variations = sum(([f"{unc}_up", f"{unc}_down"] for unc in self.jec_uncertainty_sources), [])
+    assert not (set(jer_variations) & set(jec_variations))
+    postfixes = ["", "_jer_up", "_jer_down"] + [f"_jec_{jec_var}" for jec_var in jec_variations]
+
     # 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)
 
+    # normally distributed random numbers per jet for use in stochastic smearing below
+    jer_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,
+        )
+    )
+
     # 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
     )
 
-    def get_smearing_factor(events, pt_name):
-        # variable naming convention
-        variable_map = {
-            "JetEta": events[jet_name].eta,
-            "JetPt": events[jet_name][pt_name],
-            "Rho": rho,
-        }
-
-        # pt resolution
-        inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
-        jer = ak_evaluate(self.evaluators["jer"], *inputs)
-
-        # JER scale factors and systematic variations
-        jersf = {}
-        for syst in ("nom", "up", "down"):
-            variable_map_syst = dict(variable_map, systematic=syst)
-            inputs = [variable_map_syst[inp.name] for inp in self.evaluators["sf"].inputs]
-            jersf[syst] = ak_evaluate(self.evaluators["sf"], *inputs)
-
-        # array with all JER scale factor variations as an additional axis
-        # (note: axis needs to be regular for broadcasting to work correctly)
-        jersf = ak.concatenate(
-            [jersf[syst][..., None] for syst in ("nom", "up", "down")],
-            axis=-1,
-        )
-
-        # -- stochastic smearing
-        # normally distributed random numbers according to JER
-        jer_random_normal = (
-            ak_random(0, jer, events[jet_name].deterministic_seed, rand_func=self.deterministic_normal)
-            if self.deterministic_seed_index >= 0
-            else ak_random(0, jer, rand_func=np.random.Generator(
-                np.random.SFC64(events.event.to_list())).normal,
-            )
-        )
-
-        # scale random numbers according to JER SF
-        jersf2_m1 = jersf ** 2 - 1
-        add_smear = np.sqrt(ak.where(jersf2_m1 < 0, 0, jersf2_m1))
-
-        # broadcast over JER SF variations
-        jer_random_normal, jersf_z = ak.broadcast_arrays(jer_random_normal, add_smear)
-
-        # compute smearing factors (stochastic method)
-        smear_factors_stochastic = 1.0 + jer_random_normal * add_smear
-
-        # -- scaling method (using gen match)
-
-        # 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_jets = padded_gen_jets[valid_gen_jet_idxs]
+    # prepare evaluator variables
+    variable_map = {
+        "JetEta": events[jet_name].eta,
+        "JetPt": events[jet_name].pt,
+        "Rho": rho,
+        "systematic": jer_nom,
+    }
 
-        # compute the relative (reco - gen) pt difference
-        pt_relative_diff = (events[jet_name][pt_name] - matched_gen_jets.pt) / events[jet_name][pt_name]
+    # extract nominal pt resolution
+    inputs = [variable_map[inp.name] for inp in self.evaluators["jer"].inputs]
+    jerpt = {jer_nom: ak_evaluate(self.evaluators["jer"], *inputs)}
+
+    # for simplifications below, use the same values for jer variations
+    jerpt[jer_up] = jerpt[jer_nom]
+    jerpt[jer_down] = jerpt[jer_nom]
+
path: columnflow/calibration/cms/jets.py
user: login: dsavoiu
body: Ah right, sorry, I must have misread `jer_up` as `jec_up`. All good, thanks for checking!
created_at: 2025-04-28T13:14:08Z
author_association: MEMBER

PR comments:
user: login: pkausw
created_at: 2025-04-02T09:56:22Z
body: Thanks for the PR @JulesVandenbroeck ! I'm tagging @dsavoiu here since he's the one that implemented this. Given that he's a L3 JetMet convener, I'm surprised that this implementation is not correct - do you have a source for this? I know that often times, the resolution smearing and scale corrections for objects are supposed to be applied separately, so it would be good to have a quotable source. Maybe Daniel can also comment on this himself.
author_association: CONTRIBUTOR
user: login: JulesVandenbroeck
created_at: 2025-04-02T10:19:16Z
body: I don't have a quotable source (yet) as JERC recommendations are discussed separately [here](https://cms-jerc.web.cern.ch/Recommendations/). This came from general intuition of uncertainties that the up/down variation of an uncertainty should still have all other nominal corrections applied. Otherwise you are comparing this up/down variation with effects of other corrrections in there. From a more practical point of view: This means that if an arbitrary correction would shifts the jet pT by 20%  but not for the JEC uncertainty variations on the jet pT of let's say 5%. Both up (+5%) and down (-5%) would have a variatons of 25% and 15% for up and down respectively. This makes this the JEC uncertainty one-sided while it should not be. Below you can find a more subtle example of this happening in my analysis
![image](https://github.com/user-attachments/assets/4dfc4d5a-7279-4eed-8aa2-da49e669af3c)

author_association: CONTRIBUTOR
user: login: JulesVandenbroeck
created_at: 2025-04-04T10:15:49Z
body: The `smear_factors` are in this pull request calculated with the nominal jet pt even when for the jet pt of each JEC uncertainty variation (up/down). The smear_factor should rather be calculated using the `Jet.pt_jec_{jec_uncertainty_name}_{shift}` column.
author_association: CONTRIBUTOR
user: login: riga
created_at: 2025-04-04T12:40:37Z
body: I also had a closer look and I think you're right. At the moment, nominal JER is only applied to nominal JEC. Instead, the JER producer should output

- `Jet.{pt,mass}` → central JER evaluated after central JEC
- `Jet.{pt,mass}_jer_{up,down}` → up/down JER evaluated after central JEC
- `Jet.{pt,mass}_jec_SOURCE_{up,down}` → nominal JER evaluated after JEC variations (**currently not done**).

This would not result in additional columns, but just update `Jet.{pt,mass}_jec_SOURCE_{up,down}` columns. Just to verify, is this what you intended @JulesVandenbroeck ?

Still, we should wait for @dsavoiu 's feedback before taking further action.
author_association: MEMBER
user: login: JulesVandenbroeck
created_at: 2025-04-14T09:33:03Z
body: @riga @dsavoiu 
Sorry for the delayed response to all your suggestions. I believe I implemented all the suggested changes and also fixed the smearing factor not being recalculated for all the jec variations. If there are remaining suggestions or improvements to be made, let me know! 

author_association: CONTRIBUTOR
user: login: riga
created_at: 2025-04-14T16:46:55Z
body: Highly appreciated @JulesVandenbroeck , thanks!

I'm going to have a look tomorrow.
author_association: MEMBER
user: login: riga
created_at: 2025-04-17T07:59:14Z
body: Hi @JulesVandenbroeck ,

I just had another deeper look and ran the updated recipe for our analysis. Actually, I'm good with the all the changes (modulo some simplifications that I think are possible), but I noticed that for us (unsurprisingly tbh) the runtime is now seriously increased.

We use deterministic seeds to make the stochastic part random yet reproducible. There are no tools yet to draw from a normal distribution with a vector of seeds in a vectorized fashion, so we have to go for an event loop here which naturally introduces a bottleneck.

With the additional loop here, we do this many times even though we aim to use the same seed per jet. I first thought about moving the random number generation outside your `get_smearing_factor` loop but this somewhat obfuscates the otherwise clean code. A way to avoid this would be to double down on @dsavoiu 's approach to vectorize over different jet variations (which actually should be removed in the current `get_smearing_factor` since up/down variations of jer itself should not be needed on top of jec uncertainties; one just doesn't notice it because the vectorization makes it really fast ;) ).

I am currently working on this and I will open a PR to your `upstream/fix_jerc_calibration` branch in a bit, which - when merged - would automatically update *this* PR.
author_association: MEMBER
user: login: riga
created_at: 2025-04-28T12:18:15Z
body: All comments and discussions are addressed, so we should be ready to merge.

Since this PR is targeting the `master` branch, a port is required to the refactoring for v0.3 that I will do right away. In that process I will address two recommendations made during the review:

- Rename `jerpt` back to `jer`.
- Move non-shift related initializations to the new `post_init`.
author_association: MEMBER