Skip to content

Batch plotting

An analysis rarely needs one plot. It needs every variable, under each selection, drawn linear and logarithmic. rf.PlotBook describes that set once and runs rf.plot for each member: it is repeated rf.plot(...), not a second plotting engine.

import rootfig as rf

ww = rf.Sample("ww.root", tree="events", label="WW", weight="mc_weight")
zz = rf.Sample("zz.root", tree="events", label="ZZ", weight="mc_weight")
signal = rf.Sample("signal.root", tree="events", label="Signal", weight="mc_weight")

book = rf.PlotBook(
    [rf.Group([ww, zz], label="VV"), signal],
    [
        rf.Variable("mass", bins=(50, 100, 150), unit="GeV"),
        rf.Variable("pt", bins=(50, 0, 200), unit="GeV"),
    ],
    selections={
        "baseline": rf.Cut("nMuon >= 2"),
        "sr": rf.Cut("nMuon >= 2") & "recoil_mass > 120",
    },
    variants={
        "lin": {},
        "log": {"logy": True},
    },
    plot_kwargs={"stack": True, "style": rf.Style(experiment="ATLAS", status="Internal")},
)

book.save("plots", formats=["pdf", "png"])

This writes eight plots in two formats, plots/mass__baseline__lin.pdf, plots/mass__baseline__lin.png, ... plots/pt__sr__log.png.

The three axes

The tasks of a book are the Cartesian product

variables × selections × variants

in insertion order, variables outermost and variants innermost. Two variables, three selections and two variants give twelve tasks, and book.tasks() lists them without reading or drawing anything.

Variables are names, expressions or Variable objects; one may be given bare (rf.PlotBook(data, "mass")). Variable.safe_name (the name=, else safe_file_stem of the expression) identifies a variable and names its files, so two variables of one book must not share it: Variable("Muon_pt", name="pt") next to Variable("Electron_pt", name="pt") is rejected.

Selections are a mapping from a name to a cut (a string, a Cut or None for "no cut"). Names are the file name component, never derived from the expression or the cut's label.

Variants are a mapping from a name to plot() keywords. A variant's keywords override the common plot_kwargs for its tasks, so

book = rf.PlotBook(
    data,
    ["mass"],
    plot_kwargs={"stack": True, "logy": False},
    variants={"lin": {}, "log": {"logy": True}},
)

draws lin with stack=True, logy=False and log with stack=True, logy=True. With variants={"stacked": {"stack": True}, "overlaid": {"stack": ["Background"]}}, every plot is drawn with the signal inside the stack and over it, from one set of prepared histograms. With the signal last and panel="s/sqrt(b)" in plot_kwargs, both show the same significance panel.

Every other keyword of rf.plot (observed=, normalize=, panel=, systematics=, style=, ...) goes into plot_kwargs or a variant, also functions=: with plot_kwargs={"functions": functions} every variable and selection of the book may call functions of your own, and the branches they are called on are read with the rest of the batch. The keyword names are checked against rf.plot's signature when the book is built, so a misspelt log_y is reported, with logy as the suggestion, before anything is drawn rather than after the first tasks have written their files; the values are validated by rf.plot itself. Five keywords are the book's own and are rejected: data, variable and selection come from the task, save from PlotBook.save(), and ax because every task draws its own figure.

A reserved keyword, an empty selections= or variants=, an unusable name and a pair of tasks whose files would collide all raise ValueError when the book is built. A selections=, variants= or plot_kwargs= that is not a mapping, a name that is not a string, and a keyword rf.plot does not take, raise TypeError.

Selection and variant names become file name components and are checked, like Variable.name, for every platform: more than dots and spaces, no trailing dot or space, no slash, backslash, control character or <>:"|?*, and not a Windows device name (CON, NUL, COM1, ...). The check is check_file_stem; its message offers a spelling that works.

Automatic variable discovery

rf.ALL in place of the variable list asks the book to find the variables itself:

book = rf.PlotBook(
    samples,
    variables=rf.ALL,
)

Discovery reads metadata only. For a TTree or RNTuple it is the schema: every branch (or nested field, Muon.pt) whose values are numbers or booleans, lists and fixed-size arrays of them included, becomes a variable; strings, records and other objects are left out. For a ROOT file read without a tree it is the object list: every stored TH1 becomes a variable; TH2, TProfile, TParameter and the like do not, and a histogram inside a directory is named by its path (sel/mz). A file holding both is treated as rf.plot treats it: the branches of its one tree plus the stored TH1s no branch shadows; an explicit tree= means branches only, and several trees without tree= raise, as they do for any plot. In-memory arrays contribute their numeric and boolean columns. No event array and no bin content is read, and the first file of a sample stands for all of them, as everywhere in rootfig.

With several samples, groups (through their leaf samples) and observed=, a variable must be present in every one of them, the same way: a name that is a branch in one sample and a stored histogram in another is not a common variable. Variants that change how the histograms are prepared (tree, observed, weight, ...) must all be able to plot it too, so the discovered set is the intersection over the effective configurations. The files or arrays a Systematic.samples variation fills or reads from are surveyed like a sample of their own and take part in the intersection, in the mode of the sample they vary; a variation that cannot be built or surveyed is left to the task, which reports it whatever the variable. A branch that a branch-replacement systematic replaces is kept only when its replacement is a plottable branch of the sample, since the replacement is read whenever the branch is. Stored histograms are left out whenever the book is bound to refuse them: a selection, a weight, nonfinite="error", a stats box, or a systematic varying the weight or branches that applies to a sample (the plot's unless the sample's own source of that name replaces it, none for observed data), in the book's keywords or in any variant's. A file of stored histograms only then fails when the book is built, saying why, rather than at its first task; the message lists what each sample holds, what was left out and why, and which names the samples do not share.

include= and exclude= narrow the set with case-sensitive shell patterns (*, ?, [...], as fnmatch reads them), one or a sequence:

book = rf.PlotBook(
    samples,
    variables=rf.ALL,
    include=["Muon_*", "Electron_*", "MET*"],
    exclude=["*_cov", "*Index"],
)

A variable is kept when it matches one include pattern (all do when include is not given) and no exclude pattern. Patterns match the source name, sel/mz or jet1_b-tag, not the file name component sel_mz made from it. They are only valid with rf.ALL: an explicit list is used as it is, and include= or exclude= next to one raises. So does a filter that leaves nothing; a pattern that matches nothing is fine as long as others do.

The result is an ordinary tuple of Variable objects, sorted by source name, each addressing exactly its branch or histogram (`jet1_b-tag` and `sel/mz` in backticks). book.variables shows exactly what was discovered, and from there on nothing distinguishes the book from one built with that list: the same file names (two names that sanitise to one component, a-b and a_b, are rejected as for an explicit list; exclude one or name them), the same tasks(), select() (which keeps the discovered variables rather than discovering again) and the same batched execution described below.

rf.discover_variables(data, ...) runs the same discovery without building a book, under the same selections=, variants=, plot_kwargs=, include= and exclude= keywords, and returns the variables. It does not check output file names, so two names that sanitise to one component are both returned; tell them apart with Variable.replace(name=...) and pass the list to PlotBook:

variables = rf.discover_variables(samples, include="jet*")
renamed = [v.replace(name="jet1_btag") if v.expression == "`jet1_b-tag`" else v for v in variables]
book = rf.PlotBook(samples, renamed)

Data passes through unchanged

data is stored as given and handed to rf.plot as is, never copied or flattened; with an explicit variable list it is not inspected either, and rf.ALL reads its metadata only. Every form of data that rf.plot accepts can therefore be used in a book: file paths and globs, Sample objects, Group objects (drawn as one histogram), a Group as observed=, variables that name a TH1 stored in the files, in-memory arrays and hist.Hist or Histogram objects. The rules of rf.plot apply unchanged: a book always names at least one variable (rf.ALL needs inputs with names to discover, so histogram objects take an explicit one), and a selection or weight raises for a ready-made histogram, which is drawn as it is.

The book copies the mappings it is configured with (the variable list, selections, variants and each keyword mapping) into read-only copies, so adding to or replacing entries of those dictionaries afterwards does not change the book. The values inside them are shared, not copied: a Style, a Group or a systematics= mapping given in plot_kwargs is the caller's object, and changing it changes what the book draws.

File names

book.save(directory, formats=("pdf",), **savefig_kwargs) creates the directory if needed and writes one file per task and format, named

<variable>[__<selection>][__<variant>].<format>

A component appears whenever its axis was given explicitly, whatever the name:

Book File
rf.PlotBook(data, ["mass"]) mass.pdf
rf.PlotBook(data, ["mass"], selections={"all": cut}) mass__all.pdf
rf.PlotBook(data, ["mass"], variants={"default": {"logy": True}}) mass__default.pdf
both explicit mass__sr__log.pdf

One format may be given bare, formats="png", a leading dot is accepted and duplicates are dropped. A format matplotlib cannot write raises ValueError before the first figure is drawn, rather than part way through the batch. The remaining savefig_kwargs go to Plot.save; format and fname are refused, because the file names come from formats and the task.

Two tasks that would share a name are rejected when the book is built, not when the second file overwrites the first. Names are compared ignoring case and Unicode normalisation, since lin and LIN are one file on the case-insensitive file systems of macOS and Windows; the message lists the spellings that clash. save() returns the written paths in task order, then format order, and closes every figure after writing it, also when writing fails, so memory stays bounded however large the book is.

One multipage PDF

book.save_pdf(path) writes every task into a single PDF, several plots per page, arranged automatically:

book.save_pdf("overview.pdf")

The two output modes differ only in where the plots land:

book.save("plots/")          -> one file per task
book.save_pdf("plots.pdf")   -> one multipage PDF

The plots follow book.tasks() order and fill each page row by row, and each is drawn as rf.plot would draw it on a figure of its own: the same panels, broken axis, style, labels and legend, inside one cell of the page. Nothing is added to a cell; a title= in plot_kwargs or a variant appears as it would on a standalone figure.

By default the grid of a page follows the plots it holds. Plots without a lower panel or broken axis go up to 2 × 3 on a page; when one of the plots that would land on a page has a panel= or an xbreak=, which need the room, the page stops at 2 × 2. The last page adapts to what is left rather than leaving a lone plot in the corner of a full grid: seven plain plots are a 2 × 3 page followed by a 1 × 1 one, eight a 2 × 3 page and a 1 × 2 one. layout=(rows, columns) fixes the grid of every page instead, the last one included; its unused cells stay empty:

book.save_pdf("overview.pdf", layout=(2, 3))

A page is as large as the grid of figures the plots would have on their own, plus a narrow gap between neighbouring cells, so a plot in a cell keeps its usual size: every cell of the document is as large as the largest figure any plot of the book would draw alone (a lower panel makes it taller, a Style with a figsize sets its own). A cell is laid out exactly as a figure of its size, whatever the grid and the style: the gap above a lower panel, the room for labels, the legend and the y limits are the same on a 1 × 1 page as on an 8 × 8 one. The plots of a page are finished together, so a dense page takes about as long as the same plots on pages of their own. figsize= on save_pdf() sets the size of the whole page instead, the cells sharing what the gaps leave; one too small for the grid is refused before anything is drawn:

book.save_pdf("overview.pdf", figsize=(16, 10))

Each cell carries its own style, so plots with different fonts, sizes or colours sit side by side. The one thing a cell cannot have of its own is the page background, which belongs to the figure the whole page is drawn on: plots sharing a page whose styles ask for different backgrounds are refused before anything is drawn, since the labels and legend of a cell are drawn outside its axes and would land on the wrong background. Give them pages of their own to keep one document, or write a document per style:

book.save_pdf("overview.pdf", layout=(1, 1))  # one plot per page
for name in ("light", "dark"):  # or one file per style
    book.select(variants=name).save_pdf(f"overview-{name}.pdf")

A figsize in plot_kwargs or a variant has no meaning inside a shared page and is rejected before anything is read; pass it to save_pdf(). What a task runs with decides: a figsize in plot_kwargs that every variant overrides with None leaves no task with one and is accepted.

.pdf is appended to a path without a suffix ("overview" writes overview.pdf), parent directories are created, another suffix raises ValueError and an existing directory IsADirectoryError. The remaining keywords go to PdfPages.savefig for every page (dpi= for rasterised parts), except metadata=, the document information dictionary ({"Title": ...}), which describes the whole PDF and is applied to it; format, fname, figure and backend are refused. The document is written to a temporary file next to its final name and renamed onto it once every page is done, so a failure part way leaves an existing file as it was and no half-written PDF behind. Pages are written one after another and each page's figure is closed before the next begins, so memory stays bounded however many plots the book holds.

The histograms are prepared exactly as for plots() and save(), with the same batched reads and the same reuse of one preparation for the variants that only change the drawing (see below), so rf.ALL composes naturally:

rf.PlotBook(
    "analysis.root",
    rf.ALL,
    exclude=["*_cov", "*Index"],
).save_pdf("overview.pdf")

writes an overview of every plottable branch of the file, and book.select(variables=["Muon_pt", "Muon_eta"]).save_pdf("muons.pdf") one of a subset. Errors keep their type and gain the task's note as for plots(); a failure while writing a page adds one naming the page and the file.

Running plots yourself

book.plots() is a lazy iterator of (task, plot) pairs: one figure per step, nothing drawn ahead of time. The plots are ordinary Plot objects, so this is the place to adjust a figure or keep it open:

for task, p in book.plots():
    print(task.variable.safe_name, task.selection_name, task.variant_name)
    p.ax.axvline(91.2, color="gray", ls="--")
    p.save(f"plots/{task.stem}.pdf")
    p.close()

A PlotTask carries the variable, the selection (Cut or None), the selection_name and variant_name (None for an axis the book was built without), the merged kwargs and the file stem. Tasks compare and hash by their stem, so they work as set members and dictionary keys whatever the keyword values hold. Each plot is what

rf.plot(book.data, task.variable, selection=task.selection, **task.kwargs)

returns: the same histograms, binning, systematics, labels and errors.

The book only reads less often than that call would. It runs the tasks in batches of a few dozen variables: the branches those variables and every selection need are read once per sample for the batch, the files a Systematic.samples variation fills from included, and variants that only change the drawing (stack, logy, normalize, panel, style, ...) are drawn from one set of prepared histograms, each figure from its own copy. A variant that changes how the histograms are prepared (bins, weight, observed, systematics, ...) is prepared on its own, still from the batch's read. Variables that name histograms stored in the files are read with one pass over each file per batch. A book with an explicit variable list inspects no input when it is built; one built with rf.ALL inspects the metadata of its inputs then, but never reads event arrays or histogram contents. Neither reads anything for tasks(); the first batch is read when the first plot is requested and the next one when the iteration reaches it.

Errors stop the book at the failing task. They keep their type and gain a note naming the task, so a traceback for a typo in one expression reads

rootfig.errors.MissingBranchError: unknown name 'recoil_mas' ...
while running PlotBook task variable='recoil_mas', selection='sr', variant='log'

The words all and default in such messages stand for an axis that was not given; they are never used to decide a file name.

Subsets

book.select(variables=..., selections=..., variants=...) returns a new book restricted to the named variables (by safe_name), selections and variants (by key). Each argument left as None keeps its axis, the book's order is kept rather than the filter's, and an unknown name raises with the available choices:

book.select(variables=["mass"], variants=["log"]).save("plots/debug")

book.select(variables=["foo"])
# ValueError: unknown variable 'foo'; available: ['mass', 'pt']

A filter that names nothing, select(variables=[]), raises as well, rather than quietly producing an empty book.

An axis the book was built without has the single choice "all" (selections) or "default" (variants); selecting it is a no-op and the axis stays implicit.

A complete example: histogram files

An analysis can write one histogram file per process. Files that already contain expected yields are read as they are. A Group combines the WW and ZZ processes into the VV background category.

import rootfig as rf

# Histogram files: one per process, already scaled to 5 ab^-1
ww = rf.Sample("outputs/p8_ee_WW_ecm240.root", label="WW")
zz = rf.Sample("outputs/p8_ee_ZZ_ecm240.root", label="ZZ")
zh = rf.Sample("outputs/p8_ee_ZH_ecm240.root", label="ZH")
samples = [rf.Group([ww, zz], label="VV"), zh]

fcc = rf.Style(experiment="FCC-ee", status="Simulation", com="240 GeV", lumi="5 ab^-1")
variants = {
    "stack": {"stack": True},  # ZH on top of VV
    "nostack": {"stack": ["VV"]},  # VV stacked, ZH drawn over it
    "log": {"stack": ["VV"], "logy": True},
}

# every histogram the files share, at a glance
rf.PlotBook(samples, rf.ALL, variants=variants, plot_kwargs={"style": fcc}).save_pdf("overview.pdf")

# selected plots, with bins and a range for each histogram
variables = [
    rf.Variable("zmumu_recoil_m", bins=(200, 120, 140), label="Recoil mass", unit="GeV"),
    rf.Variable("zmumu_m", bins=(50, 86, 96), label=r"$m_{\mu\mu}$", unit="GeV"),
    rf.Variable("zmumu_p", bins=(400, 0, 80), label=r"$p_{\mu\mu}$", unit="GeV"),
]
rf.PlotBook(samples, variables, variants=variants, plot_kwargs={"style": fcc}).save_pdf("zh.pdf")

The files are read once per batch of variables. The three variants of each variable are drawn from one preparation, so changing the drawing does not repeat the reads or the binning.

The stored 1 MeV recoil bins become 0.1 GeV bins between 120 and 140 GeV. Everything outside that interval moves into the underflow or overflow bin, including its uncertainty. rf.ALL keeps each histogram's own binning, which suits an overview; explicit Variables choose the ranges and bins to show.

The same plot can be made from ntuples storing the recoil mass as a branch. Give the samples cross sections and generated-event counts to scale that plot to the same luminosity:

ww = rf.Sample("ntuples/p8_ee_WW_ecm240/*.root", label="WW", xsec="16.4 pb", ngen="eventsProcessed")
zz = rf.Sample("ntuples/p8_ee_ZZ_ecm240/*.root", label="ZZ", xsec="1.36 pb", ngen="eventsProcessed")
zh = rf.Sample(
    "ntuples/p8_ee_ZH_ecm240/*.root", label="ZH", xsec="0.202 pb", ngen="eventsProcessed"
)
recoil = rf.Variable("zed_leptonic_recoil_m", bins=(200, 120, 140), label="Recoil mass", unit="GeV")
rf.plot([rf.Group([ww, zz], label="VV"), zh], recoil, lumi="5 ab^-1", stack=["VV"], style=fcc)

What a book does not do

A book runs its tasks one after another in the calling process and keeps the arrays of one batch of variables at a time. Its outputs are the individual files of save() and the compact overview of save_pdf(): it has no filename template, no parallel execution, and no report generation beyond that grid of plots (no captions, tables of contents, headers, HTML or slides). It drives the 1D rf.plot only: for plot2d, efficiency and profile you write the loop yourself.