Writing Modules¶
This page walks through how to write custom analysis modules. If the built-in modules do not cover your use case, writing a custom module is the primary way to extend the framework. You will almost certainly need to write at least a couple modules for your analysis, so it is good to undestand how this is done.
Module Basics¶
A module is a Python class that:
Inherits from
AnalyzerModule(or another module base class).Uses the
@definedecorator fromattrs.
![digraph lifecycle {
rankdir=LR;
bgcolor="transparent";
node [shape=box, style="rounded,filled", fillcolor="#f8fafc", color="#cbd5e1", fontname="Helvetica", penwidth=1.5];
edge [color="#64748b", fontname="Helvetica", fontsize=10, penwidth=1.2];
Start [shape=oval, fillcolor="#f1f5f9", label="Module Execution"];
Inputs [label="inputs()\nDetermine dependencies", fillcolor="#e0f2fe"];
CacheCheck [shape=diamond, fillcolor="#fef3c7", color="#fcd34d", label="Cache\nHit?"];
Run [label="run()\nProcess columns", fillcolor="#dbeafe", color="#60a5fa"];
Outputs [label="outputs()\nDeclare new columns", fillcolor="#e0f2fe"];
End [shape=oval, fillcolor="#f1f5f9", label="Return Columns & Results"];
Start -> Inputs;
Inputs -> CacheCheck;
CacheCheck -> End [label=" Yes ", color="#10b981", fontcolor="#059669"];
CacheCheck -> Run [label=" No "];
Run -> Outputs;
Outputs -> End;
}](../_images/graphviz-cdf8f879b8f9d95bf931dfb1d3ed071690188861.png)
Here is a minimal example:
from attrs import define
from analyzer.core.analysis_modules import AnalyzerModule
from analyzer.core.columns import Column
@define
class MyJetCounter(AnalyzerModule):
"""Count jets and store the result as a column."""
input_col: Column
output_col: Column
def inputs(self, metadata):
return [self.input_col]
def outputs(self, metadata):
return [self.output_col]
def run(self, columns, params):
import awkward as ak
jets = columns[self.input_col]
n_jets = ak.num(jets, axis=1)
columns[self.output_col] = n_jets
return columns, []
This module reads a jet collection, counts the jets per event, and stores the count.
In the YAML configuration, it would be used as:
- module_name: MyJetCounter
input_col: GoodJet
output_col: NJet
Registering Your Module¶
The framework discovers modules through Python’s import system.
For modules defined in the analyzer/modules/ directory, they are auto-discovered.
For modules defined elsewhere, use the extra_module_paths field in the configuration:
extra_module_paths:
- path/to/my_modules.py
The file will be imported before the configuration is parsed, making your module classes available for use in pipeline definitions.
The module_name field in the YAML must exactly match the class name.
Working with TrackedColumns¶
The columns argument to run() is a TrackedColumns object.
See Columns and Data for the full API.
Common operations:
def run(self, columns, params):
# Read a column
jets = columns[self.input_col]
# Access individual fields, note that this must be declared as an input
jet_pt = columns["GoodJet.pt"]
# Metadata
is_mc = columns.metadata["sample_type"].value == "MC"
# Write a column
columns[self.output_col] = result
return columns, []
Creating Selections¶
To add a selection (boolean mask), use the addSelection() helper:
from analyzer.core.columns import addSelection
@define
class MySelection(AnalyzerModule):
input_col: Column
selection_name: str
threshold: float
def inputs(self, metadata):
return [self.input_col]
def outputs(self, metadata):
return [Column("Selection", self.selection_name)]
def run(self, columns, params):
import awkward as ak
values = columns[self.input_col]
mask = values > self.threshold
addSelection(columns, self.selection_name, mask)
return columns, []
The selection is stored under Selection.<name> and tracked in pipeline_data["Selections"] for later application by SelectOnColumns.
Producing Histograms¶
To produce a histogram from your module, use the makeHistogram() helper:
from analyzer.modules.common.histogram_builder import makeHistogram
from analyzer.modules.common.axis import RegularAxis
@define
class MyHTHistogram(AnalyzerModule):
input_col: Column
def inputs(self, metadata):
return [self.input_col, Column("Weights")]
def outputs(self, metadata):
return []
def run(self, columns, params):
ht = columns[self.input_col]
hist_result = makeHistogram(
"my_ht_histogram",
columns,
RegularAxis(name="HT", start=0, stop=3000, bins=60, unit="GeV"),
ht,
)
return columns, [hist_result]
makeHistogram() returns a ModuleAddition that tells the framework to create a HistogramBuilder sub-pipeline.
The histogram will automatically include:
A
variationaxis with entries for each systematic variation (even no systematics are present, there is always a “central” systematic).Any category axes defined in
pipeline_data["categories"].Proper weighting from
Weightscolumns, based on weights added by previous modules.
Conditional Execution¶
Add a should_run parameter to make your module conditionally executable:
from analyzer.core.analysis_modules import AnalyzerModule, MetadataExpr
@define
class MCSFCorrection(AnalyzerModule):
should_run: MetadataExpr | None = MetadataExpr.IsSampleType("MC")
# ... rest of module
The should_run field defaults to None (always run), but can be overridden in the YAML config or set to a sensible default in the class definition.
Common defaults:
MetadataExpr.IsSampleType("MC"): Only MC samples.MetadataExpr.IsSampleType("Data"): Only Data.MetadataExpr.IsYear("2018"): Only 2018 era.
Declaring Dynamic Parameters (Systematics)¶
If your module introduces a systematic variation, override getParameterSpec():
from analyzer.core.param_specs import ParameterSpec
@define
class MyCorrection(AnalyzerModule):
input_col: Column
output_col: Column
def getParameterSpec(self, metadata):
return {
"my_systematic": ParameterSpec(
default_value="central",
possible_values={"central", "up", "down"},
tags={"shape_variation"},
)
}
def run(self, columns, params):
variation = params.get("my_systematic", "central")
jets = columns[self.input_col]
if variation == "up":
jets = modify_up(jets)
elif variation == "down":
jets = modify_down(jets)
columns[self.output_col] = jets
return columns, []
def inputs(self, metadata):
return [self.input_col]
def outputs(self, metadata):
return [self.output_col]
See Systematics for more details on how the parameter spec and run builder systems interact.
Testing Your Module¶
The fastest way to test a module is with the ImmediateExecutor and a small event count:
./osca run -e imm-10000 \
--max-sample-events 1000 \
--filter-dataset 'qcd*2018*' \
--log-level DEBUG \
config/analysis.yaml test_output/
With --log-level DEBUG, you can see exactly which modules are executing, what their cache keys are, and when cache hits occur.
You can also use standard Python debugging tools (breakpoint(), print()) since the immediate executor runs in a single process.
This is super helpful for debuggging!!
Common Patterns¶
Accessing era-specific corrections:
def run(self, columns, params):
era = columns.metadata["era"]
correction_file = era["corrections"]["pileup_sf"]
# Load and apply correction...
# See existing modules for examples of how you can cache things to multi-runs don't reload the data.
Producing multiple results:
def run(self, columns, params):
result1 = makeHistogram("hist1", columns, axis1, data1)
result2 = makeHistogram("hist2", columns, axis2, data2)
return columns, [result1, result2]