11 """ Compare ROOT objects and perform e.g. chi2 tests.
12 A small command line interface for testing/debugging purposes is included.
13 Run `python3 validationcomparison.py --help` for more information. """
16 from abc
import ABC, abstractmethod
20 from typing
import Optional, Any
26 from metaoptions
import MetaOptionParser
38 class ComparisonFailed(Exception):
40 The comparison failed for some reason. For example
41 because ROOT was not able to compute the Chi^2 properly
45 class ObjectsNotSupported(Exception):
47 The type and/or combination of provided ROOT objects
48 is not supported for comparison
52 class DifferingBinCount(Exception):
54 The two ROOT objects provided have a different bin count
55 and therefor, cannot be compared using the Chi2 test
59 class TooFewBins(Exception):
61 Not sufficient bins to perform the Chi^2 test
71 object_1, object_2, mop: Optional[MetaOptionParser] =
None,
72 ) ->
"ComparisonBase":
73 """ Uses the metaoptions to determine which comparison algorithm is used
74 and initializes the corresponding subclass of :class:`ComparisonBase` that
75 implements the actual comparison and holds the results.
76 @param object_1 ROOT TObject
77 @param object_2 ROOT TObject
78 @param mop Metaoption parser
81 mop = MetaOptionParser()
82 if mop.has_option(
"kolmogorov"):
83 tester: Any = KolmogorovTest
84 elif mop.has_option(
"andersondarling"):
85 tester = AndersonDarlingTest
89 test = tester(object_1, object_2, mop=mop)
99 class ComparisonBase(ABC):
101 Base class for all comparison implementations.
105 1. Initialize the class together with two ROOT objects of different
106 revisions (that are to be compared) and the metaoptions (given in the
107 corresponding validation (steering) file), that determine how to compare
110 2. The Comparison class saves the ROOT objects and the metaoptions
111 internally, but does not compute anything yet
113 3. If :meth:`ensure_compute` is called, or any property is accessed that
114 depends on computation, the internal implementation :meth:`_compute`
115 (to be implemented in the subclass) is called.
117 4. :meth:`_compute` ensures that all values, like chi2, p-value etc. are
120 5. Two properties :meth:`comparison_result` (pass/warning/error) and
121 :meth:`comparison_result_long` (longer description of the comparison result)
122 allow to access the results.
129 mop: Optional[MetaOptionParser] =
None,
133 Initialize ComparisonBase class
137 :param mop: MetaOptionParser
138 :param debug (bool): Debug mode enabled?
141 self.object_a = object_a
144 self.object_b = object_b
148 mop = MetaOptionParser()
155 self.computed =
False
158 self._comparison_result =
"not_compared"
161 self._comparison_result_long =
""
163 def ensure_compute(self):
165 Ensure all required quantities get computed and are cached inside the
171 if self.mop.has_option(
"nocompare"):
173 self._comparison_result_long =
"Testing is disabled for this plot"
176 fail_message =
"Comparison failed: "
181 except ObjectsNotSupported
as e:
182 self._comparison_result_long = fail_message + str(e)
183 except DifferingBinCount
as e:
184 self._comparison_result =
"error"
185 self._comparison_result_long = fail_message + str(e)
186 except TooFewBins
as e:
187 self._comparison_result_long = fail_message + str(e)
188 except ComparisonFailed
as e:
189 self._comparison_result =
"error"
190 self._comparison_result_long = fail_message + str(e)
191 except Exception
as e:
192 self._comparison_result =
"error"
193 self._comparison_result_long = (
194 "Unknown error occurred. Please "
195 "submit a bug report. " + str(e)
200 self._comparison_result_long = self._get_comparison_result_long()
201 self._comparison_result = self._get_comparison_result()
206 def _get_comparison_result(self) -> str:
207 """ Used to format the value of :attr:`_comparison_result`. """
210 def _get_comparison_result_long(self) -> str:
211 """ Used to format the value of :attr:`_comparison_result_long`. """
214 def comparison_result(self):
215 """ Comparison result, i.e. pass/warning/error """
216 self.ensure_compute()
217 return self._comparison_result
220 def comparison_result_long(self):
221 """ Longer description of the comparison result """
222 self.ensure_compute()
223 return self._comparison_result_long
227 """ This method performs the actual computations. """
229 def can_compare(self):
231 @return: True if the two objects can be compared, False otherwise
233 return self._has_correct_types()
and self._has_compatible_bins()
235 def _has_correct_types(self) -> bool:
237 @return: True if the two objects have a) a type supported for
238 comparison and b) can be compared with each other
240 if self.object_a
is None or self.object_b
is None:
245 supported_types = [
"TProfile",
"TH1D",
"TH1F",
"TEfficiency"]
246 if self.object_a.ClassName() != self.object_b.ClassName():
248 if self.object_a.ClassName()
not in supported_types:
251 if self.object_a.ClassName() ==
"TEfficiency":
253 if self.object_a.GetDimension() > 1:
258 def _raise_has_correct_types(self) -> None:
260 Raise Exception if not the two objects have a) a type supported for
261 comparison and b) can be compared with each other
264 if not self._has_correct_types():
266 "Comparison of {} (Type {}) with {} (Type {}) not "
267 "supported.\nPlease open a GitLab issue (validation "
268 "label) if you need this supported. "
270 raise ObjectsNotSupported(
272 self.object_a.GetName(),
273 self.object_a.ClassName(),
274 self.object_b.GetName(),
275 self.object_b.ClassName(),
279 def _has_compatible_bins(self) -> bool:
281 Check if both ROOT obeject have the same amount of bins
282 @return: True if the bins are equal, otherwise False
285 self.object_a.ClassName()
287 == self.object_b.ClassName()
289 nbins_a = self.object_a.GetTotalHistogram().GetNbinsX()
290 nbins_b = self.object_b.GetTotalHistogram().GetNbinsX()
292 nbins_a = self.object_a.GetNbinsX()
293 nbins_b = self.object_b.GetNbinsX()
295 return nbins_a == nbins_b
297 def _raise_has_compatible_bins(self) -> None:
299 Raise Exception if not both ROOT obeject have the same amount of bins
302 if not self._has_compatible_bins():
304 "The objects have differing x bin count: {} has {} vs. {} "
307 raise DifferingBinCount(
309 self.object_a.GetName(),
310 self.object_a.GetNbinsX(),
311 self.object_b.GetName(),
312 self.object_b.GetNbinsX(),
317 def _convert_teff_to_hist(teff_a):
319 Convert the content of a TEfficiency plot to a histogram and set
320 the bin content and errors
322 conv_hist = teff_a.GetTotalHistogram()
323 xbin_count = conv_hist.GetNbinsX()
324 xbin_low = conv_hist.GetXaxis().GetXmin()
325 xbin_max = conv_hist.GetXaxis().GetXmax()
328 teff_a.GetName() +
"root_conversion",
336 for i
in range(1, xbin_count):
337 th1.SetBinContent(i, teff_a.GetEfficiency(i))
338 th1.SetBinError(i, teff_a.GetEfficiencyErrorLow(i))
343 class PvalueTest(ComparisonBase):
344 """ Test with a pvalue """
348 _default_pvalue_warn = 0.99
352 _default_pvalue_error = 0.01
354 def __init__(self, *args, **kwargs):
355 """ Initialize Pvalue test
358 *args: Positional arguments to ComparisonBase
359 **kwargs: Keyword arguments to ComparisonBase
361 super().__init__(*args, **kwargs)
365 self._pvalue_warn = self.mop.pvalue_warn()
367 self._pvalue_error = self.mop.pvalue_error()
369 if self._pvalue_warn
is None:
370 self._pvalue_warn = self._default_pvalue_warn
371 if self._pvalue_error
is None:
372 self._pvalue_error = self._default_pvalue_error
374 def _get_comparison_result(self) -> str:
375 if self._pvalue
is None:
378 if self._pvalue < self._pvalue_error:
380 elif self._pvalue < self._pvalue_warn:
390 def _get_comparison_result_long(self):
403 class Chi2Test(PvalueTest):
406 Perform a Chi2Test for ROOT objects. The chi2 test method is e.g. described
407 in the documentation of TH1::Chi2Test. Basically this class wraps around
408 this Chi2Test function, and takes care that we can call perform these
409 tests for a wider selection of ROOT objects.
412 def __init__(self, *args, **kwargs):
415 :param args: See arguments of :class:`ComparisonBase`
416 :param kwargs: See arguments of :class:`ComparisonBase`
418 super().__init__(*args, **kwargs)
429 def _ensure_zero_error_has_no_content(self, a, b):
431 Ensure there are no bins which have a content set, but 0 error
432 This bin content will be set to 0 to disable this bin completely during
435 nbins = a.GetNbinsX()
436 for ibin
in range(1, nbins + 1):
437 if a.GetBinError(ibin) <= 0.0
and b.GetBinError(ibin) <= 0.0:
440 a.SetBinContent(ibin, 0.0)
441 b.SetBinContent(ibin, 0.0)
444 "DEBUG: Warning: Setting bin content of bin {} to "
445 "zero for both histograms, because both histograms "
446 "have vanishing errors there.".format(ibin)
449 def _compute(self) -> None:
451 Performs the actual Chi^2 test
454 self._raise_has_correct_types()
455 self._raise_has_compatible_bins()
457 local_object_a = self.object_a
458 local_object_b = self.object_b
461 if self.object_a.ClassName() ==
"TEfficiency":
462 local_object_a = self._convert_teff_to_hist(self.object_a)
463 local_object_b = self._convert_teff_to_hist(self.object_b)
465 print(
"Converting TEfficiency objects to histograms.")
467 nbins = local_object_a.GetNbinsX()
471 "{} bin(s) is too few to perform the Chi2 "
472 "test.".format(nbins)
475 weighted_types = [
"TProfile",
"TH1D",
"TH1F"]
476 comp_weight_a = local_object_a.ClassName()
in weighted_types
477 comp_weight_b = local_object_b.ClassName()
in weighted_types
481 first_obj = local_object_a.Clone()
482 second_obj = local_object_b.Clone()
484 if comp_weight_a
and not comp_weight_b:
487 first_obj, second_obj = second_obj, first_obj
490 "Debug: Warning: Switching the two objects, because "
491 "ROOT can only have the first one to be unweighted"
496 if comp_weight_a
and comp_weight_b:
498 elif comp_weight_a
or comp_weight_b:
503 if comp_weight_a
and comp_weight_b:
504 self._ensure_zero_error_has_no_content(first_obj, second_obj)
507 res_chi2 = numpy.array([1], numpy.float64)
508 res_igood = numpy.array([1], numpy.int32)
509 res_ndf = numpy.array([1], numpy.int32)
511 res_pvalue = first_obj.Chi2TestX(
512 second_obj, res_chi2, res_ndf, res_igood, comp_options
516 print(
"Performing our own chi2 test, with bin-by-bin results: ")
518 print_contents_and_errors(first_obj, second_obj)
521 "Here's what ROOT's Chi2Test gave us (comp_options: '{}'):"
522 " ".format(comp_options)
525 tp = TablePrinter(3, width=(10, 10, 40))
528 tp.print([
"Key",
"Value",
"Comment"])
533 numpy.asscalar(res_chi2),
534 "Should roughly match above 'Total chi2'",
537 tp.print([
"ndf", numpy.asscalar(res_ndf),
"#Non-empty bins - 1"])
538 tp.print([
"chi2/ndf", numpy.asscalar(res_chi2 / res_ndf),
""])
542 numpy.asscalar(res_igood),
543 "a debug indicator, 0 if all good",
546 tp.print([
"pvalue", res_pvalue,
""])
550 "See https://root.cern.ch/doc/master/classTH1.html for more "
557 "Comparison failed, no Chi^2 could be computed. For "
558 "debugging, you can use the CLI of "
559 "'validation/scripts/validationcomparison.py' on your root "
560 "file and the reference. Run 'validationcomparison.py "
561 "--help' for info. If problem persists, please open "
562 "GitLab issue (validation label)."
564 raise ComparisonFailed(msg)
566 res_chi2ndf = res_chi2 / res_ndf
568 self._pvalue, self._chi2, self._chi2ndf, self._ndf = (
575 def _get_comparison_result_long(self) -> str:
576 if self._pvalue
is None or self._chi2ndf
is None or self._chi2
is None:
578 r"Could not perform $\chi^2$-Test between {{revision1}} "
579 r"and {{revision2}} due to an unknown error. Please "
580 r"submit a bug report."
584 r"Performed $\chi^2$-Test between {{revision1}} "
585 r"and {{revision2}} "
586 r"($\chi^2$ = {chi2:.4f}; NDF = {ndf}; "
587 r"$\chi^2/\text{{{{NDF}}}}$ = {chi2ndf:.4f})."
588 r" <b>p-value: {pvalue:.6f}</b> (p-value warn: {pvalue_warn}, "
589 r"p-value error: {pvalue_error})".format(
592 chi2ndf=self._chi2ndf,
594 pvalue_warn=self._pvalue_warn,
595 pvalue_error=self._pvalue_error,
605 class KolmogorovTest(PvalueTest):
606 """ Kolmogorov-Smirnov Test """
608 def __init__(self, *args, **kwargs):
610 Initialize Kolmogorov test.
611 @param args: See arguments of :class:`ComparisonBase`
612 @param kwargs: See arguments of :class:`ComparisonBase`
614 super().__init__(*args, **kwargs)
618 Perform the actual test
621 self._raise_has_correct_types()
622 self._raise_has_compatible_bins()
624 local_object_a = self.object_a
625 local_object_b = self.object_b
628 if self.object_a.ClassName() ==
"TEfficiency":
629 local_object_a = self._convert_teff_to_hist(self.object_a)
630 local_object_b = self._convert_teff_to_hist(self.object_b)
632 print(
"Converting TEfficiency objects to histograms.")
638 self._pvalue = local_object_a.KolmogorovTest(local_object_b, option_str)
640 def _get_comparison_result_long(self) -> str:
641 if self._pvalue
is None:
643 r"Could not perform Kolmogorov test between {{revision1}} "
644 r"and {{revision2}} due to an unknown error. Please submit "
649 r"Performed Komlogorov test between {{revision1}} "
650 r"and {{revision2}} "
651 r" <b>p-value: {pvalue:.6f}</b> (p-value warn: {pvalue_warn}, "
652 r"p-value error: {pvalue_error})".format(
654 pvalue_warn=self._pvalue_warn,
655 pvalue_error=self._pvalue_error,
665 class AndersonDarlingTest(PvalueTest):
666 """ Anderson-Darling test"""
668 def __init__(self, *args, **kwargs):
670 Initialize Anderson-Darling test.
671 @param args: See arguments of :class:`ComparisonBase`
672 @param kwargs: See arguments of :class:`ComparisonBase`
674 super().__init__(*args, **kwargs)
678 Perform the actual test
681 self._raise_has_correct_types()
686 local_object_a = self.object_a
687 local_object_b = self.object_b
690 if self.object_a.ClassName() ==
"TEfficiency":
691 local_object_a = self._convert_teff_to_hist(self.object_a)
692 local_object_b = self._convert_teff_to_hist(self.object_b)
694 print(
"Converting TEfficiency objects to histograms.")
700 self._pvalue = local_object_a.AndersonDarlingTest(
701 local_object_b, option_str
704 def _get_comparison_result_long(self) -> str:
705 if self._pvalue
is None:
707 r"Could not perform-Anderson Darling test between "
708 r"{{revision1}} and {{revision2}} due to an unknown error."
709 r" Please support a bug report."
713 r"Performed Anderson-Darling test between {{revision1}} "
714 r"and {{revision2}} "
715 r" <b>p-value: {pvalue:.6f}</b> (p-value warn: {pvalue_warn}, "
716 r"p-value error: {pvalue_error})".format(
718 pvalue_warn=self._pvalue_warn,
719 pvalue_error=self._pvalue_error,
730 """ A tiny class to print columns of fixed width numbers. """
732 def __init__(self, ncols, width=None):
735 @param ncols: Number of columns
736 @param width: Width of each column. Either int or list.
742 if isinstance(width, int):
744 self.widths = [width] * ncols
745 elif isinstance(width, list)
or isinstance(width, tuple):
751 """ Total width of the table """
754 width += sum(self.widths)
756 width += (self.ncols - 1) * 3
761 def print_divider(self, char="="):
762 """ Print a divider made up from repeated chars """
763 print(char * self.tot_width)
765 def print(self, cols):
766 """ Print one row """
767 assert len(cols) == self.ncols
769 for icol, col
in enumerate(cols):
770 width = self.widths[icol]
771 if isinstance(col, int):
772 form = f
"{{:{width}d}}"
773 out.append(form.format(col))
774 elif isinstance(col, float):
775 form =
"{{:{}.{}f}}".format(width, width // 2)
776 out.append(form.format(col))
780 col = col[:width].rjust(width)
782 print(
"| " +
" | ".join(out) +
" |")
785 def print_contents_and_errors(obj_a, obj_b):
787 Print contents, errors and chi2 deviation for each bin as well as
788 some other information about two TH1-like objects.
789 @param obj_a: First TH1-like object
790 @param obj_b: Second TH1-like object
793 nbins = obj_a.GetNbinsX()
795 total_a = sum([obj_a.GetBinContent(ibin)
for ibin
in range(0, nbins + 2)])
796 total_b = sum([obj_b.GetBinContent(ibin)
for ibin
in range(0, nbins + 2)])
798 print(f
"Total events/summed weights in object 1: {total_a:10.5f}")
799 print(f
"Total events/summed weights in object 2: {total_b:10.5f}")
806 cp.print([
"ibin",
"a",
"err a",
"b",
"err b",
"chi2"])
808 for ibin
in range(1, nbins + 1):
809 content_a = obj_a.GetBinContent(ibin)
810 content_b = obj_b.GetBinContent(ibin)
811 error_a = obj_a.GetBinError(ibin)
812 error_b = obj_b.GetBinError(ibin)
816 chi2 = (total_b * content_a - total_a * content_b) ** 2 / (
817 total_b ** 2 * error_a ** 2 + total_a ** 2 * error_b ** 2
820 except ZeroDivisionError:
822 cp.print([ibin, content_a, error_a, content_b, error_b, chi2])
826 print(f
"Total chi2: {chi2_tot:10.5f}")
835 """ A small command line interface for debugging purposes. """
841 "For testing purposes: Run the chi2 comparison with objects from "
844 parser = argparse.ArgumentParser(desc)
846 _ =
"Rootfile to read the first object from"
847 parser.add_argument(
"rootfile_a", help=_)
849 _ =
"Name of object inside first rootfile."
850 parser.add_argument(
"name_a", help=_)
852 _ =
"Rootfile to read the second object from"
853 parser.add_argument(
"rootfile_b", help=_)
855 _ =
"Name of object inside second rootfile."
856 parser.add_argument(
"name_b", help=_)
858 args = parser.parse_args()
863 if not os.path.exists(args.rootfile_a):
864 raise ValueError(f
"Could not find '{args.rootfile_a}'.")
866 if not os.path.exists(args.rootfile_b):
867 raise ValueError(f
"Could not find '{args.rootfile_b}'.")
869 rootfile_a = ROOT.TFile(args.rootfile_a)
870 obj_a = rootfile_a.Get(args.name_a)
873 f
"Could not find object '{args.name_a}' "
874 f
"in file '{args.rootfile_a}'."
877 rootfile_b = ROOT.TFile(args.rootfile_b)
878 obj_b = rootfile_b.Get(args.name_b)
881 f
"Could not find object '{args.name_b}' "
882 f
"in file '{args.rootfile_b}'."
888 test = Chi2Test(obj_a, obj_b, debug=
True)
889 test.ensure_compute()
891 print(
"If you see this message, then no exception was thrown.")
900 if __name__ ==
"__main__":