Writing Custom Postprocessors

If the built-in postprocessors (Histogram1D, RatioPlot, etc.) do not meet your needs, you can write custom postprocessors and transforms.

Custom Postprocessor

A postprocessor is a class that inherits from BasePostprocessor and implements getRunFuncs().

from attrs import define
from analyzer.postprocessing.processors import BasePostprocessor
import functools as ft

@define
class MyCustomPlot(BasePostprocessor):
    """A custom postprocessor that produces some output."""
    output_name: str

    def getRunFuncs(self, group, prefix=None):
        # 'group' is the data structured by the GroupBuilder.
        # It may be a list of ItemWithMeta, a dict, or other nested structure.
        from analyzer.utils.structure_tools import commonDict, dictToDot, dotFormat

        common_meta = commonDict(group)
        output_path = dotFormat(
            self.output_name, prefix=prefix, **dict(dictToDot(common_meta))
        )

        yield ft.partial(self._makePlot, group, output_path)

    @staticmethod
    def _makePlot(group, output_path):
        import matplotlib.pyplot as plt

        # Extract data from group and create your plot
        fig, ax = plt.subplots()

        # ... plotting logic ...
        # matplotlib calls, mplhep, etc

        fig.savefig(output_path)
        plt.close(fig)

Key points:

  • getRunFuncs() is a generator that yields callables (typically built using functools.partial).

  • Each callable will be executed by the postprocessing framework, potentially in parallel.

  • The group parameter contains the data structured by the GroupBuilder (selected, grouped, transformed, and sub-grouped).

  • Since we use processes for parallelism, the callables must be picklable if --parallel is used.

Registration

Custom postprocessors are automatically discovered through Python’s subclass mechanism. As long as your class inherits from BasePostprocessor and is imported before the postprocessing configuration is loaded, it will be available.

The name field in the YAML configuration must match the class name. The class is resolved via cattrs tagged union with tag_name="name".

Danger

If you are implementing your postprocessor in a new file it must be imported by the framework!

Custom Transform

Transforms operate on lists of ItemWithMeta objects and return modified lists.

from attrs import define
from analyzer.postprocessing.transforms.registry import TransformHistogram
from analyzer.utils.structure_tools import ItemWithMeta
from analyzer.core.results import Histogram

@define
class MakeVariancesBigger(TransformHistogram):
    """Multiply all histogram values by a constant."""
    scale_factor: float

    def __call__(self, items: list[ItemWithMeta]):
        ret = []
        for item, meta in items:
            h = item.histogram.copy(deep=True)
            h.view()[...] *= self.scale_factor
            ret.append(ItemWithMeta(
                Histogram(name=item.name, axes=item.axes, histogram=h),
                metadata=meta,
            ))
        return ret

Transform base classes:

Like postprocessors, custom transforms are discovered via subclass resolution and the name field in YAML.

transforms:
  - name: MakeVariancesBigger
    scale_factor: 1.5