ATLAS Offline Software
Loading...
Searching...
No Matches
physvalPostProcessingTools.py
Go to the documentation of this file.
1# Copyright (C) 2002-2025 CERN for the benefit of the ATLAS collaboration
2
3import ROOT
4import re
5import yaml
6import logging
7
8crash_on_error = False
9
10
11def find_histo(root_file, path, should_delete=False):
12 obj = root_file.Get(path)
13 if not obj:
14 if crash_on_error:
15 raise RuntimeError(f"Object not found at path: {path}")
16 else:
17 logging.warning(f"Object not found at path: {path}")
18 return None
19 if should_delete:
20 # Extract directory and name for deletion
21 path_parts = path.strip("/").split("/")
22 *dirs, name = path_parts
23 current_dir = root_file
24 for d in dirs:
25 current_dir = current_dir.Get(d)
26 if not current_dir:
27 if crash_on_error:
28 raise RuntimeError(f"Directory '{d}' not found in path '{'/'.join(dirs)}' for deletion.")
29 else:
30 logging.warning(f"Directory '{d}' not found in path '{'/'.join(dirs)}' for deletion.")
31 return None
32 # logging.info(f"Deleting '{name}' in '{'/'.join(dirs)}'")
33 current_dir.Delete(f"{name};*")
34 return None
35 return obj
36
37
38def ensure_directory_exists(root_file, path):
39 current = root_file
40 for part in path.strip("/").split("/"):
41 next_dir = current.Get(part)
42 if not next_dir:
43 logging.info(f"Saving directory not exist. Creating directory '{part}' in '{current.GetName()}'")
44 current.mkdir(part)
45 current.cd(part)
46 current = ROOT.gDirectory
47
48
49_FIELD = re.compile(r"\{(\w+)\}")
50
51def histogram_paths(directory, prefix=""):
52 """Return the path of every histogram below a directory."""
53 paths = []
54 for key in directory.GetListOfKeys():
55 name = key.GetName()
56 path = f"{prefix}/{name}" if prefix else name
57 # only the directories are read, so the histograms stay on disk
58 stored = ROOT.TClass.GetClass(key.GetClassName())
59 if stored.InheritsFrom(ROOT.TDirectory.Class()):
60 subdir = directory.Get(name)
61 if subdir:
62 paths.extend(histogram_paths(subdir, path))
63 elif stored.InheritsFrom(ROOT.TH1.Class()):
64 paths.append(path)
65 return paths
66
67def pattern_to_regex(pattern):
68 """Turn a pattern with {field} placeholders into a regex with one group per field."""
69 regex, seen, pos = "", set(), 0
70 for field in _FIELD.finditer(pattern):
71 name = field.group(1)
72 regex += re.escape(pattern[pos:field.start()])
73 regex += f"(?P={name})" if name in seen else f"(?P<{name}>[^/]+)"
74 seen.add(name)
75 pos = field.end()
76 return re.compile(regex + re.escape(pattern[pos:]))
77
78def fill_fields(value, fields):
79 """Substitute the {field} placeholders of one fragment value."""
80 if not isinstance(value, str):
81 return value
82 return _FIELD.sub(lambda field: fields[field.group(1)], value)
83
84def empty_input(root_file, fragment, fields):
85 """Return the first of the named fragment inputs that exists but has no entries."""
86 for field in [fields] if isinstance(fields, str) else fields:
87 histo = root_file.Get(fragment[field])
88 if histo and histo.InheritsFrom(ROOT.TH1.Class()) and not histo.GetEntries():
89 return fragment[field]
90 return None
91
92def expand_fragments(fragments, root_file):
93 """Resolve the for_each patterns of a domain against the histograms in a file.
94
95 A fragment without for_each is kept as it is. One with for_each is repeated
96 once per histogram matching the pattern, with its {field} placeholders filled
97 in from the match. skip_if_empty names the inputs that a repetition needs to
98 be non-empty, which is how the selections a sample does not fill are dropped.
99 """
100 paths = None
101 expanded = []
102 for fragment in fragments:
103 pattern = fragment.get("for_each")
104 if pattern is None:
105 expanded.append(fragment)
106 continue
107 if paths is None:
108 paths = histogram_paths(root_file)
109 matches = [match.groupdict() for match in map(pattern_to_regex(pattern).fullmatch, paths) if match]
110 if not matches:
111 logging.info(f"No histogram matches '{pattern}', nothing to expand")
112 for fields in sorted(matches, key=lambda match: sorted(match.items())):
113 entry = {key: fill_fields(value, fields) for key, value in fragment.items()
114 if key not in ("for_each", "skip_if_empty")}
115 required = fragment.get("skip_if_empty")
116 empty = empty_input(root_file, entry, required) if required else None
117 if empty:
118 logging.info(f"'{empty}' is empty, skipping '{entry['output']}'")
119 continue
120 expanded.append(entry)
121 return expanded
122
124 def __init__(self):
125 self.configs = {}
126
127 def add_configuration(self, name, config_file):
128 with open(config_file, "r") as f:
129 config_data = yaml.safe_load(f)
130 self.configs[name] = config_data
131 logging.info(f"Configuration for '{name}' loaded from {config_file}")
132
133 def get_config(self, name):
134 """Returns the config for a specific domain (e.g., 'Electron')"""
135 return self.configs.get(name, {})
136
138 """Returns all configs as (name, config) pairs"""
139 return self.configs.items()
140
142 def __init__(self, input, output, rebin_factor, delete_original=False):
143 self.input = input
144 self.output = output
145 self.rebin_factor = rebin_factor
146 self.delete_original = delete_original
147
148 def __call__(self, root_file):
149 hist = find_histo(root_file, self.input)
150 if not hist:
151 if crash_on_error:
152 raise RuntimeError(f"Histogram '{self.input}' not found for rebinning.")
153 else:
154 logging.error(f"Histogram '{self.input}' not found for rebinning.")
155 return
156
157 path_parts = self.output.strip("/").split("/")
158 *dir_parts, output_name = path_parts
159 directory_path = "/".join(dir_parts)
160 ensure_directory_exists(root_file, directory_path)
161 root_file.cd(directory_path)
162
163 if hist.GetDimension() == 1:
164 rebinned = hist.Rebin(self.rebin_factor, output_name)
165 rebinned.Write(output_name, ROOT.TObject.kOverwrite)
166 logging.info(f"Rebinned 1D histogram and saved as '{output_name}' in '{directory_path}'.")
167 elif hist.GetDimension() == 2:
168 hist.RebinX(self.rebin_factor)
169 hist.RebinY(self.rebin_factor)
170 hist.SetName(output_name)
171 hist.Write(output_name, ROOT.TObject.kOverwrite)
172 logging.info(f"Rebinned 2D histogram and saved as '{output_name}' in '{directory_path}'.")
173 if self.delete_original:
174 find_histo(root_file, self.input, should_delete=True)
175
176 @staticmethod
177 def from_yaml(fragment):
178 return RebinningOperation(
179 input=fragment["input"],
180 output=fragment["output"],
181 rebin_factor=fragment["rebin"],
182 delete_original=fragment.get("delete_original", False)
183 )
184
186 def __init__(self, numerator, denominator, output):
187 self.numerator = numerator
188 self.denominator = denominator
189 self.output = output
190
191 def __call__(self, root_file):
192 num = find_histo(root_file, self.numerator)
193 den = find_histo(root_file, self.denominator)
194 if not num or not den:
195 if crash_on_error:
196 raise RuntimeError(f"Missing histogram: {self.numerator} or {self.denominator}")
197 else:
198 logging.error(f"Missing histogram: {self.numerator} or {self.denominator}")
199 return
200
201 if not ROOT.TEfficiency.CheckConsistency(num, den):
202 logging.warning("Inconsistency detected between numerator and denominator histograms.")
203 logging.warning(f"num={self.numerator} denom={self.denominator}")
204 logging.info("Attempting to fix...")
205 for i in range(1, num.GetNbinsX() + 1):
206 num_val = num.GetBinContent(i)
207 den_val = den.GetBinContent(i)
208 if den_val == 0:
209 den.SetBinContent(i, 1e-6)
210 den_val = 1e-6 # avoid zero denominator by assigning a tiny epsilon
211 logging.info(f"Setting denominator to a smaller value for bin {i} as it is zero. Any other options?")
212 if num_val > den_val:
213 logging.info(f"For Bin {i}: Num ({num_val}) > Den ({den_val})! Adjusting by setting Den to Num. Any other options?")
214 den.SetBinContent(i, num_val)
215 if not ROOT.TEfficiency.CheckConsistency(num, den):
216 logging.error("Unable to fix histogram inconsistencies. Aborting efficiency calculation.")
217 return
218
219 eff = ROOT.TEfficiency(num, den)
220 path_parts = self.output.strip("/").split("/")
221 *dir_path, obj_name = path_parts
222 directory_path = "/".join(dir_path)
223 eff.SetName(obj_name)
224 eff.SetTitle(f"{self.numerator}/{self.denominator};{num.GetXaxis().GetTitle()};Efficiency")
225 ensure_directory_exists(root_file, directory_path)
226 root_file.cd(directory_path)
227 eff.Write()
228 logging.info(f"Saved efficiency '{obj_name}' in '{directory_path}'.")
229
230 @staticmethod
231 def from_yaml(fragment):
233 numerator=fragment["numerator"],
234 denominator=fragment["denominator"],
235 output=fragment["output"]
236 )
237
239 # Computes the s68 and s90 resolution widths for a histogram.
240 def __init__(self, histogram_name):
241 self.histogram_name = histogram_name
242
243 def __call__(self, root_file):
244 hist = find_histo(root_file, self.histogram_name)
245 if not hist:
246 if crash_on_error:
247 raise RuntimeError(f"Histogram '{self.histogram_name}' not found.")
248 else:
249 logging.error(f"Histogram '{self.histogram_name}' not found.")
250 return
251
252 # Step 1: Compute normalized cumulative sum
253 cum = [hist.GetBinContent(i) for i in range(0, hist.GetNbinsX() + 2)]
254 for i in range(1, len(cum)):
255 cum[i] += cum[i - 1]
256 total = cum[-1] or 1.0
257 cum = [x / total for x in cum]
258
259 # Step 2: Compute smallest intervals
260 s68 = self._compute_s(hist, cum, 0.683)
261 s90 = self._compute_s(hist, cum, 0.90)
262
263 logging.info(f"Resolution widths for {self.histogram_name} -> s68: {s68:.4f}, s90: {s90:.4f}")
264 logging.info("Todo: Take a 2D histo, take the quantity say xaxis, make its small intervals %s", "and then compute the Resolution widths and then save as a 1D histo with quantity on xaxis and Resolution widths on yaxis")
265
266 def _compute_s(self, hist, cum, prob):
267 # Compute the smallest interval that contains the given probability.
268 min_width = float("inf")
269 best = (0, 0)
270 for i in range(len(cum)):
271 target = cum[i] + prob
272 if target > 1:
273 break
274 for j in range(i + 1, len(cum)):
275 if cum[j] >= target:
276 width = hist.GetBinCenter(j) - hist.GetBinCenter(i)
277 if width < min_width:
278 min_width = width
279 best = (i, j)
280 break
281 return 0.5 * (hist.GetBinCenter(best[1]) - hist.GetBinCenter(best[0]))
282
283 @staticmethod
284 def from_yaml(fragment):
286 histogram_name=fragment["histogram"]
287 )
288
290 def __init__(self, signal_path, background_path, output):
291 self.signal_path = signal_path
292 self.background_path = background_path
293 self.output = output
294
295 def __call__(self, root_file):
296 sig = find_histo(root_file, self.signal_path)
297 bkg = find_histo(root_file, self.background_path)
298
299 if not sig or not bkg:
300 if crash_on_error:
301 raise RuntimeError(f"Missing histogram: {self.signal_path} or {self.background_path}")
302 else:
303 logging.error(f"Missing histogram: {self.signal_path} or {self.background_path}")
304 return
305
306 graph = self.compute_roc(sig, bkg)
307 if not graph:
308 logging.error("ROC graph is empty or could not be constructed.")
309 return
310
311 path_parts = self.output.strip("/").split("/")
312 *dir_path, obj_name = path_parts
313 directory_path = "/".join(dir_path)
314
315 graph.SetName(obj_name)
316 graph.SetTitle(f"{obj_name};Signal Efficiency;Background Rejection")
317
318 ensure_directory_exists(root_file, directory_path)
319 root_file.cd(directory_path)
320 graph.Write()
321 logging.info(f"Saved ROC curve '{obj_name}' in '{directory_path}'.")
322
323 def compute_roc(self, sig, bkg):
324 n_bins = sig.GetNbinsX()
325 total_sig = sig.Integral()
326 total_bkg = bkg.Integral()
327
328 x_vals, y_vals = [], []
329 if total_sig == 0 or total_bkg == 0:
330 logging.error("Zero total signal or background counts; cannot compute ROC.")
331 return None
332
333 for cut_bin in range(1, n_bins + 1): # Loops over histogram bins, each corresponding to a cut value on the tagger output.
334 sig_pass = sig.Integral(cut_bin, n_bins)
335 bkg_pass = bkg.Integral(cut_bin, n_bins)
336
337 eff = sig_pass / total_sig # Signal efficiency eff = (number of b-jets passing cut) / (total b-jets).
338
339 if bkg_pass <= 0:
340 # logging.info(f"Skipping cut bin {cut_bin} due to zero or negative background pass count: {bkg_pass}. Any other options?")
341 continue
342
343 rej = total_bkg / bkg_pass # Background rejection rej = (total background jets) / (number of background jets passing cut).
344
345 x_vals.append(eff)
346 y_vals.append(rej)
347
348 if not x_vals:
349 return None
350
351 graph = ROOT.TGraphErrors(len(x_vals))
352 for i, (x, y) in enumerate(zip(x_vals, y_vals)):
353 graph.SetPoint(i, x, y)
354
355 return graph
356
357 @staticmethod
358 def from_yaml(fragment):
359 return ROCCurveComputation(
360 signal_path=fragment["signal"],
361 background_path=fragment["background"],
362 output=fragment["output"]
363 )
364
366 def __init__(self, input_hist, bins_x, bins_y, projection_axis, output=None):
367 self.input_hist = input_hist
368 self.bins_x = bins_x
369 self.bins_y = bins_y
370 self.x_proj = projection_axis == "x"
371 self.y_proj = projection_axis == "y"
372 self.output = output
373
374 def __call__(self, root_file):
375 hist = find_histo(root_file, self.input_hist)
376 if not hist:
377 if crash_on_error:
378 raise RuntimeError(f"Histogram '{self.input_hist}' not found.")
379 else:
380 logging.error(f"Histogram '{self.input_hist}' not found.")
381 return
382
383 b1, b2 = hist.GetYaxis().FindBin(self.bins_x[0]), hist.GetYaxis().FindBin(self.bins_x[1])
384 b3, b4 = hist.GetXaxis().FindBin(self.bins_y[0]), hist.GetXaxis().FindBin(self.bins_y[1])
385
386 path_parts = self.output.strip("/").split("/")
387 *dir_path, output_name = path_parts
388 directory_path = "/".join(dir_path)
389 ensure_directory_exists(root_file, directory_path)
390 root_file.cd(directory_path)
391
392 if self.x_proj:
393 proj_x = hist.ProjectionX(output_name, b1, b2).Clone(output_name)
394 self.project(proj_x, hist.GetXaxis().GetTitle(), "X")
395 if self.y_proj:
396 proj_y = hist.ProjectionY(output_name, b3, b4).Clone(output_name)
397 self.project(proj_y, hist.GetYaxis().GetTitle(), "Y")
398
399 def project(self, projection, axis_title, axis):
400 projection.SetTitle(f";{axis_title};Entries")
401 projection.SetStats(False)
402 projection.Write()
403 logging.info(f"Saved projection by integrating over {axis} axis as '{projection.GetName()}'.")
404
405 @staticmethod
406 def from_yaml(fragment):
408 input_hist=fragment["hist"],
409 bins_x=fragment.get("bins_projection_x", [10, 200]),
410 bins_y=fragment.get("bins_projection_y", [-2.47, 2.47]),
411 projection_axis=fragment.get("projection_axis", "x"),
412 output=fragment.get("output")
413 )
414
416 def __init__(self, histo1, histo2, output, delete_inputs=False):
417 self.histo1 = histo1
418 self.histo2 = histo2
419 self.output = output
420 self.delete_inputs = delete_inputs
421
422 def __call__(self, root_file):
423 histo1 = find_histo(root_file, self.histo1)
424 histo2 = find_histo(root_file, self.histo2)
425 if not histo1 or not histo2:
426 if crash_on_error:
427 raise RuntimeError(f"Missing histograms: {self.histo1} or {self.histo2}")
428 else:
429 logging.error(f"Missing histograms: {self.histo1} or {self.histo2}")
430 return
431
432 path_parts = self.output.strip("/").split("/")
433 *dir_path, output_name = path_parts
434 directory_path = "/".join(dir_path)
435 summed = histo1.Clone(output_name)
436 summed.Add(histo2)
437 ensure_directory_exists(root_file, directory_path)
438 root_file.cd(directory_path)
439 summed.Write(output_name, ROOT.TObject.kOverwrite)
440 logging.info(f"Saved added histogram '{output_name}' in '{directory_path}'.")
441 if self.delete_inputs:
442 # logging.info(f"Deleting input histograms '{self.histo1}' and '{self.histo2}' after addition.")
443 find_histo(root_file, self.histo1, should_delete=True)
444 find_histo(root_file, self.histo2, should_delete=True)
445
446 @staticmethod
447 def from_yaml(fragment):
448 return HistogramAddition(
449 histo1=fragment["histo1_name"],
450 histo2=fragment["histo2_name"],
451 output=fragment["output"],
452 delete_inputs=fragment.get("delete_inputs", False)
453 )
454
456 def __init__(self, input_hist, new_path, x_label=None, y_label=None, xlow=None, xhigh=None):
457 self.input_hist = input_hist
458 self.new_path = new_path
459 self.x_label = x_label
460 self.y_label = y_label
461 self.xlow = xlow
462 self.xhigh = xhigh
463
464 def __call__(self, root_file):
465 ROOT.TH1.AddDirectory(False)
466 hist = find_histo(root_file, self.input_hist)
467 if not hist:
468 if crash_on_error:
469 raise RuntimeError(f"Histogram '{self.input_hist}' not found.")
470 else:
471 logging.error(f"Histogram '{self.input_hist}' not found.")
472 return
473
474 path_parts = self.new_path.strip("/").split("/")
475 *dir_path, new_name = path_parts
476 directory_path = "/".join(dir_path)
477 ensure_directory_exists(root_file, directory_path)
478 root_file.cd(directory_path)
479
480 if self.xlow is None or self.xhigh is None:
481 h_out = hist.Clone(new_name)
482 else:
483 ax = hist.GetXaxis()
484 bin_low = max(1, min(ax.FindBin(self.xlow), ax.GetNbins()))
485 bin_high = max(1, min(ax.FindBin(self.xhigh), ax.GetNbins()))
486 nbins = bin_high - bin_low
487 h_out = ROOT.TH1D(new_name, hist.GetTitle(), nbins, float(self.xlow), float(self.xhigh))
488 for i in range(nbins + 1):
489 h_out.SetBinContent(i + 1, hist.GetBinContent(i + bin_low))
490 h_out.SetBinError(i + 1, hist.GetBinError(i + bin_low))
491 # let's retain entries in the full range -- more meaningful than default (number of bins)
492 h_out.SetEntries(hist.GetEntries())
493
494 if self.x_label:
495 h_out.GetXaxis().SetTitle(self.x_label)
496 if self.y_label:
497 h_out.GetYaxis().SetTitle(self.y_label)
498 h_out.Write(new_name, ROOT.TObject.kOverwrite)
499 logging.info(f"Renamed and updated histogram '{self.input_hist}' → '{self.new_path}' in '{directory_path}' | X-axis: '{self.x_label}' | Y-axis: '{self.y_label}' | Range: ({self.xlow}, {self.xhigh})")
500
501 @staticmethod
502 def from_yaml(fragment):
503 return HistogramAdjustment(
504 input_hist=fragment["hist"],
505 new_path=fragment.get("final_histo_name", fragment["hist"]),
506 x_label=fragment.get("x_axis_label"),
507 y_label=fragment.get("y_axis_label"),
508 xlow=fragment.get("xlow"),
509 xhigh=fragment.get("xhigh")
510 )
511
513 def __init__(self, toDelete):
514 self.toDelete = toDelete
515
516 def __call__(self, root_file):
517 find_histo(root_file, self.toDelete, should_delete=True)
518
519 @staticmethod
520 def from_yaml(fragment):
521 return HistogramBlackList(
522 toDelete=fragment["toDelete"]
523 )
#define min(a, b)
Definition cfImp.cxx:40
#define max(a, b)
Definition cfImp.cxx:41
STL class.
__init__(self, histo1, histo2, output, delete_inputs=False)
__init__(self, input_hist, new_path, x_label=None, y_label=None, xlow=None, xhigh=None)
__init__(self, input_hist, bins_x, bins_y, projection_axis, output=None)
__init__(self, input, output, rebin_factor, delete_original=False)
STL class.
T * get(TKey *tobj)
get a TObject* from a TKey* (why can't a TObject be a TKey?)
Definition hcg.cxx:132
std::vector< std::string > split(const std::string &s, const std::string &t=":")
Definition hcg.cxx:179
find_histo(root_file, path, should_delete=False)
— HELPER — ###
ensure_directory_exists(root_file, path)
— HELPER — ###