rootfig 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rootfig/__init__.py +97 -0
- rootfig/_typing.py +21 -0
- rootfig/api.py +1297 -0
- rootfig/errors.py +73 -0
- rootfig/expressions/__init__.py +34 -0
- rootfig/expressions/functions.py +151 -0
- rootfig/expressions/parser.py +462 -0
- rootfig/histograms/__init__.py +67 -0
- rootfig/histograms/build.py +197 -0
- rootfig/histograms/cutflow.py +195 -0
- rootfig/histograms/efficiency.py +214 -0
- rootfig/histograms/normalize.py +124 -0
- rootfig/histograms/pipeline.py +186 -0
- rootfig/histograms/ratio.py +161 -0
- rootfig/histograms/stats.py +183 -0
- rootfig/io/__init__.py +12 -0
- rootfig/io/sources.py +487 -0
- rootfig/model/__init__.py +27 -0
- rootfig/model/binning.py +237 -0
- rootfig/model/cuts.py +85 -0
- rootfig/model/samples.py +284 -0
- rootfig/model/style.py +144 -0
- rootfig/model/units.py +107 -0
- rootfig/model/variables.py +95 -0
- rootfig/plotting/__init__.py +78 -0
- rootfig/plotting/annotations.py +165 -0
- rootfig/plotting/correlation.py +93 -0
- rootfig/plotting/figure.py +458 -0
- rootfig/plotting/hist1d.py +421 -0
- rootfig/plotting/hist2d.py +59 -0
- rootfig/plotting/points.py +78 -0
- rootfig/plotting/ratio.py +163 -0
- rootfig/plotting/result.py +131 -0
- rootfig/plotting/style.py +308 -0
- rootfig/py.typed +0 -0
- rootfig/selection/__init__.py +23 -0
- rootfig/selection/columns.py +479 -0
- rootfig-0.1.0.dist-info/METADATA +202 -0
- rootfig-0.1.0.dist-info/RECORD +41 -0
- rootfig-0.1.0.dist-info/WHEEL +4 -0
- rootfig-0.1.0.dist-info/licenses/LICENSE +21 -0
rootfig/api.py
ADDED
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
"""The high-level API: :func:`plot`, :func:`histogram`, :func:`load` and friends.
|
|
2
|
+
|
|
3
|
+
Everything here is a thin orchestration of the lower layers
|
|
4
|
+
(:mod:`rootfig.io`, :mod:`rootfig.expressions`, :mod:`rootfig.selection`,
|
|
5
|
+
:mod:`rootfig.histograms`, :mod:`rootfig.plotting`), which remain usable on
|
|
6
|
+
their own.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import awkward as ak
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from rootfig._typing import FloatArray, Hist
|
|
21
|
+
from rootfig.errors import RootfigWarning, SelectionError, SourceError
|
|
22
|
+
from rootfig.expressions import parse
|
|
23
|
+
from rootfig.histograms import (
|
|
24
|
+
SIGNIFICANCE_KINDS,
|
|
25
|
+
CutflowTable,
|
|
26
|
+
Histogram,
|
|
27
|
+
NormalizeSpec,
|
|
28
|
+
ProfileStatistic,
|
|
29
|
+
RatioUncertainty,
|
|
30
|
+
SignificanceKind,
|
|
31
|
+
Summary,
|
|
32
|
+
as_weight_storage,
|
|
33
|
+
build_histograms,
|
|
34
|
+
build_histograms_2d,
|
|
35
|
+
combined_selection,
|
|
36
|
+
correlation_matrix,
|
|
37
|
+
describe_table,
|
|
38
|
+
load_columns,
|
|
39
|
+
read_arrays,
|
|
40
|
+
significance,
|
|
41
|
+
)
|
|
42
|
+
from rootfig.histograms import cutflow as cutflow_of
|
|
43
|
+
from rootfig.histograms import efficiency as efficiency_of
|
|
44
|
+
from rootfig.histograms import normalize as normalize_histogram
|
|
45
|
+
from rootfig.histograms import profile as profile_of
|
|
46
|
+
from rootfig.histograms import summarize as summarize_columns
|
|
47
|
+
from rootfig.model import (
|
|
48
|
+
Bins,
|
|
49
|
+
CutLike,
|
|
50
|
+
RangeSpec,
|
|
51
|
+
Sample,
|
|
52
|
+
StyleLike,
|
|
53
|
+
Variable,
|
|
54
|
+
as_cut,
|
|
55
|
+
as_samples,
|
|
56
|
+
as_style,
|
|
57
|
+
as_variable,
|
|
58
|
+
resolve_axis,
|
|
59
|
+
)
|
|
60
|
+
from rootfig.model.samples import HistType
|
|
61
|
+
from rootfig.plotting import (
|
|
62
|
+
AxesLike,
|
|
63
|
+
FlowSpec,
|
|
64
|
+
Plot,
|
|
65
|
+
add_experiment_label,
|
|
66
|
+
add_legend,
|
|
67
|
+
add_stats_box,
|
|
68
|
+
apply_xbreak,
|
|
69
|
+
break_segments,
|
|
70
|
+
color_cycle,
|
|
71
|
+
draw_correlation,
|
|
72
|
+
draw_efficiencies,
|
|
73
|
+
draw_hist2d,
|
|
74
|
+
draw_histograms,
|
|
75
|
+
draw_profiles,
|
|
76
|
+
draw_ratio_panel,
|
|
77
|
+
draw_significance_panel,
|
|
78
|
+
envelope,
|
|
79
|
+
finish_axes,
|
|
80
|
+
fold_flow_bins,
|
|
81
|
+
label_flow_bins,
|
|
82
|
+
legend_location,
|
|
83
|
+
make_figure,
|
|
84
|
+
overlay_artists,
|
|
85
|
+
raise_ylim_above,
|
|
86
|
+
show_flow_bins,
|
|
87
|
+
style_context,
|
|
88
|
+
ylabel_for,
|
|
89
|
+
)
|
|
90
|
+
from rootfig.selection import Columns, NonFinitePolicy, boolean_mask, depth_of
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"SummaryTable",
|
|
94
|
+
"correlation",
|
|
95
|
+
"histogram",
|
|
96
|
+
"histograms",
|
|
97
|
+
"load",
|
|
98
|
+
"plot",
|
|
99
|
+
"plot2d",
|
|
100
|
+
"plot_histograms",
|
|
101
|
+
"summarize",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------------------
|
|
106
|
+
# Data access
|
|
107
|
+
# --------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load(
|
|
111
|
+
data: Any,
|
|
112
|
+
expressions: str | Sequence[str] | Mapping[str, str] | None = None,
|
|
113
|
+
*,
|
|
114
|
+
tree: str | None = None,
|
|
115
|
+
selection: CutLike | None = None,
|
|
116
|
+
entry_start: int | None = None,
|
|
117
|
+
entry_stop: int | None = None,
|
|
118
|
+
) -> ak.Array:
|
|
119
|
+
"""Read branches (or evaluate expressions) into an Awkward record array.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
data
|
|
124
|
+
File path(s), glob, ``"path:tree"``, a :class:`~rootfig.model.Sample`,
|
|
125
|
+
or in-memory arrays.
|
|
126
|
+
expressions
|
|
127
|
+
Branch names or expressions to evaluate. A mapping gives the output
|
|
128
|
+
field names explicitly (``{"pt": "Muon_pt / 1000"}``). ``None`` reads
|
|
129
|
+
every branch.
|
|
130
|
+
tree
|
|
131
|
+
Tree name when ``data`` is a file specification.
|
|
132
|
+
selection
|
|
133
|
+
An event-level boolean expression; events failing it are dropped. A
|
|
134
|
+
per-object selection raises :class:`~rootfig.errors.SelectionError`
|
|
135
|
+
(apply object cuts inside the expressions instead, e.g.
|
|
136
|
+
``"Muon_pt[Muon_pt > 20]"``), and so does a numeric one (an integer flag
|
|
137
|
+
would otherwise be taken as an index array; write ``"flag != 0"``).
|
|
138
|
+
entry_start, entry_stop
|
|
139
|
+
Entry range to read (ignored for a ``Sample``, which carries its own).
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
awkward.Array
|
|
144
|
+
A record array with one field per expression.
|
|
145
|
+
"""
|
|
146
|
+
sample = _single_sample(data, tree=tree, entry_start=entry_start, entry_stop=entry_stop)
|
|
147
|
+
available = sample.source.branches()
|
|
148
|
+
if expressions is None:
|
|
149
|
+
fields: dict[str, str] = {name: f"`{name}`" for name in available}
|
|
150
|
+
elif isinstance(expressions, str):
|
|
151
|
+
fields = {expressions: expressions}
|
|
152
|
+
elif isinstance(expressions, Mapping):
|
|
153
|
+
fields = dict(expressions)
|
|
154
|
+
else:
|
|
155
|
+
fields = {e: e for e in expressions}
|
|
156
|
+
if not fields:
|
|
157
|
+
msg = "no expressions to load"
|
|
158
|
+
raise SourceError(msg)
|
|
159
|
+
|
|
160
|
+
parsed = {name: parse(text) for name, text in fields.items()}
|
|
161
|
+
cut = combined_selection(sample, selection)
|
|
162
|
+
arrays, n_events = read_arrays(sample, [*parsed.values(), *([cut.parsed()] if cut else [])])
|
|
163
|
+
result = {
|
|
164
|
+
name: expression.evaluate(arrays, length=n_events) for name, expression in parsed.items()
|
|
165
|
+
}
|
|
166
|
+
if cut is not None:
|
|
167
|
+
mask = boolean_mask(cut.parsed(), arrays, length=n_events)
|
|
168
|
+
if depth_of(mask) != 1:
|
|
169
|
+
msg = (
|
|
170
|
+
f"selection {cut.expression!r} is per-object; load() only supports per-event "
|
|
171
|
+
"selections. Reduce it with any()/all()/count() or apply it inside the "
|
|
172
|
+
"expressions, e.g. 'Muon_pt[Muon_pt > 20]'"
|
|
173
|
+
)
|
|
174
|
+
raise SelectionError(msg)
|
|
175
|
+
result = {name: array[mask] for name, array in result.items()}
|
|
176
|
+
return ak.Array(result)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------------------
|
|
180
|
+
# Histograms
|
|
181
|
+
# --------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def histograms(
|
|
185
|
+
data: Any,
|
|
186
|
+
variable: str | Variable,
|
|
187
|
+
*,
|
|
188
|
+
tree: str | None = None,
|
|
189
|
+
selection: CutLike | None = None,
|
|
190
|
+
weight: str | None = None,
|
|
191
|
+
lumi: float | str | None = None,
|
|
192
|
+
bins: Bins | None = None,
|
|
193
|
+
range: RangeSpec = None,
|
|
194
|
+
label: str | Sequence[str] | None = None,
|
|
195
|
+
normalize: NormalizeSpec = None,
|
|
196
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
197
|
+
) -> list[Histogram]:
|
|
198
|
+
"""Fill one :class:`~rootfig.histograms.Histogram` per sample with shared binning.
|
|
199
|
+
|
|
200
|
+
See :func:`plot` for the meaning of the arguments; this function stops
|
|
201
|
+
before drawing.
|
|
202
|
+
"""
|
|
203
|
+
samples = as_samples(data, tree=tree, labels=label)
|
|
204
|
+
var = as_variable(variable, bins=bins, range=range)
|
|
205
|
+
hists = build_histograms(
|
|
206
|
+
samples, var, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
207
|
+
)
|
|
208
|
+
if normalize is None or normalize is False:
|
|
209
|
+
return hists
|
|
210
|
+
return [normalize_histogram(h, normalize) for h in hists]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def histogram(
|
|
214
|
+
data: Any,
|
|
215
|
+
variable: str | Variable,
|
|
216
|
+
*,
|
|
217
|
+
tree: str | None = None,
|
|
218
|
+
selection: CutLike | None = None,
|
|
219
|
+
weight: str | None = None,
|
|
220
|
+
lumi: float | str | None = None,
|
|
221
|
+
bins: Bins | None = None,
|
|
222
|
+
range: RangeSpec = None,
|
|
223
|
+
normalize: NormalizeSpec = None,
|
|
224
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
225
|
+
) -> Hist:
|
|
226
|
+
"""Fill a single histogram and return it as a plain ``hist.Hist``.
|
|
227
|
+
|
|
228
|
+
Examples
|
|
229
|
+
--------
|
|
230
|
+
>>> h = rf.histogram(
|
|
231
|
+
... "events.root", "Muon_pt", tree="events", selection="Muon_pt > 20", bins=(50, 0, 200)
|
|
232
|
+
... ) # doctest: +SKIP
|
|
233
|
+
>>> h.values().sum() # doctest: +SKIP
|
|
234
|
+
"""
|
|
235
|
+
results = histograms(
|
|
236
|
+
data,
|
|
237
|
+
variable,
|
|
238
|
+
tree=tree,
|
|
239
|
+
selection=selection,
|
|
240
|
+
weight=weight,
|
|
241
|
+
lumi=lumi,
|
|
242
|
+
bins=bins,
|
|
243
|
+
range=range,
|
|
244
|
+
normalize=normalize,
|
|
245
|
+
nonfinite=nonfinite,
|
|
246
|
+
)
|
|
247
|
+
if len(results) != 1:
|
|
248
|
+
msg = f"histogram() takes a single sample, got {len(results)}; use histograms() instead"
|
|
249
|
+
raise SourceError(msg)
|
|
250
|
+
return results[0].hist
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --------------------------------------------------------------------------------------
|
|
254
|
+
# 1D plots
|
|
255
|
+
# --------------------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def plot(
|
|
259
|
+
data: Any,
|
|
260
|
+
variable: str | Variable,
|
|
261
|
+
*,
|
|
262
|
+
tree: str | None = None,
|
|
263
|
+
selection: CutLike | None = None,
|
|
264
|
+
weight: str | None = None,
|
|
265
|
+
lumi: float | str | None = None,
|
|
266
|
+
bins: Bins | None = None,
|
|
267
|
+
range: RangeSpec = None,
|
|
268
|
+
label: str | Sequence[str] | None = None,
|
|
269
|
+
observed: Any = None,
|
|
270
|
+
xlabel: str | None = None,
|
|
271
|
+
ylabel: str | None = None,
|
|
272
|
+
unit: str | None = None,
|
|
273
|
+
title: str | None = None,
|
|
274
|
+
normalize: NormalizeSpec = None,
|
|
275
|
+
stack: bool = False,
|
|
276
|
+
ratio: RatioSpec = False,
|
|
277
|
+
ratio_ylim: tuple[float, float] | None = None,
|
|
278
|
+
ratio_label: str | None = None,
|
|
279
|
+
ratio_uncertainty: RatioUncertainty | None = None,
|
|
280
|
+
logx: bool | None = None,
|
|
281
|
+
logy: bool = False,
|
|
282
|
+
flow: FlowSpec = "hint",
|
|
283
|
+
histtype: HistType | None = None,
|
|
284
|
+
errorbars: bool | None = None,
|
|
285
|
+
xlim: tuple[float, float] | None = None,
|
|
286
|
+
ylim: tuple[float | None, float | None] | None = None,
|
|
287
|
+
xbreak: tuple[float, float] | None = None,
|
|
288
|
+
legend: bool | str | None = None,
|
|
289
|
+
stats: bool | str = False,
|
|
290
|
+
text: str | Sequence[str] | None = None,
|
|
291
|
+
style: StyleLike = None,
|
|
292
|
+
figsize: tuple[float, float] | None = None,
|
|
293
|
+
ax: AxesLike = None,
|
|
294
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
295
|
+
save: str | None = None,
|
|
296
|
+
) -> Plot:
|
|
297
|
+
"""Histogram a variable from one or more samples and draw it.
|
|
298
|
+
|
|
299
|
+
This is the ``TTree::Draw`` of rootfig: read only the branches needed,
|
|
300
|
+
evaluate the expressions, apply the selection with the documented
|
|
301
|
+
per-event/per-object semantics, fill ``hist.Hist`` objects with a binning
|
|
302
|
+
shared by all samples, and render them with mplhep.
|
|
303
|
+
|
|
304
|
+
Parameters
|
|
305
|
+
----------
|
|
306
|
+
data
|
|
307
|
+
What to plot: a file path or glob, ``"path:tree"``, a list of those (one
|
|
308
|
+
sample each), a ``{label: files}`` mapping, one or more
|
|
309
|
+
:class:`~rootfig.model.Sample` objects, or in-memory arrays (a mapping
|
|
310
|
+
of arrays or an Awkward record array).
|
|
311
|
+
variable
|
|
312
|
+
Branch name or expression (see :mod:`rootfig.expressions`), or a
|
|
313
|
+
:class:`~rootfig.model.Variable` carrying binning and labels.
|
|
314
|
+
tree
|
|
315
|
+
Tree name for file inputs; auto-detected when a file holds one tree.
|
|
316
|
+
selection
|
|
317
|
+
Boolean expression or :class:`~rootfig.model.Cut` applied before
|
|
318
|
+
filling. Per-object selections mask objects of jagged variables;
|
|
319
|
+
per-event selections drop events.
|
|
320
|
+
weight
|
|
321
|
+
Weight expression, e.g. ``"mc_weight * sf"``; per-event weights are
|
|
322
|
+
broadcast onto objects.
|
|
323
|
+
lumi
|
|
324
|
+
Integrated luminosity to scale simulated samples to, in fb^-1 or as a
|
|
325
|
+
string with a unit (``"10.8 ab^-1"``). Applies to samples with a cross
|
|
326
|
+
section (``Sample(xsec=..., ngen=...)``): their weights are multiplied
|
|
327
|
+
by ``xsec * lumi / ngen``. Also written into the label unless the style
|
|
328
|
+
already has a luminosity.
|
|
329
|
+
bins
|
|
330
|
+
Binning: an ``int`` (range inferred from the data), ``(n, low, high)``,
|
|
331
|
+
bin edges, or a ``hist`` axis. Overrides the ``Variable``'s binning.
|
|
332
|
+
range
|
|
333
|
+
Range for integer ``bins``: ``(low, high)``, ``"auto"`` or ``"robust"``.
|
|
334
|
+
label
|
|
335
|
+
Legend label(s) for samples given as plain files.
|
|
336
|
+
observed
|
|
337
|
+
A sample of observed data (or the file(s) for one) drawn as black points,
|
|
338
|
+
excluded from stacks and used as numerator of the ratio.
|
|
339
|
+
xlabel, ylabel, unit, title
|
|
340
|
+
Axis labels; defaults come from the variable, the normalisation and the
|
|
341
|
+
bin width (``Events / 2 GeV``).
|
|
342
|
+
normalize
|
|
343
|
+
``True``/``"unity"`` (sum to one), ``"density"``, ``"width"`` (divide by
|
|
344
|
+
bin width) or a number to normalise to.
|
|
345
|
+
stack
|
|
346
|
+
Stack the non-data samples.
|
|
347
|
+
ratio
|
|
348
|
+
``True`` for a ratio panel (data / total for stacks or data plots,
|
|
349
|
+
otherwise each sample over the first), a sample label to use as the
|
|
350
|
+
reference, or a significance panel: ``"significance"`` (``S/sqrt(B)``),
|
|
351
|
+
``"s/sqrt(b)"`` or ``"s/sqrt(s+b)"``, where the signal is the last
|
|
352
|
+
non-data sample (the top of a stack) and the background the sum of the
|
|
353
|
+
others; ``("s/sqrt(b)", "Signal")`` names the signal sample.
|
|
354
|
+
ratio_ylim, ratio_label, ratio_uncertainty
|
|
355
|
+
Ratio panel range, y label, and uncertainty treatment
|
|
356
|
+
(``"propagate"`` or ``"numerator"`` with a reference band).
|
|
357
|
+
logx, logy
|
|
358
|
+
Logarithmic axes. ``logx=None`` (default) follows the ``Variable``'s
|
|
359
|
+
``log`` flag; ``True``/``False`` override it.
|
|
360
|
+
flow
|
|
361
|
+
Under/overflow display: ``"hint"`` (arrows), ``"show"`` (extra bins),
|
|
362
|
+
``"sum"`` (added to the edge bins, also for ratios and limits), ``"none"``.
|
|
363
|
+
histtype
|
|
364
|
+
Default drawing style for non-data samples: ``"step"``, ``"fill"``,
|
|
365
|
+
``"errorbar"`` or ``"band"``.
|
|
366
|
+
errorbars
|
|
367
|
+
Draw statistical error bars on non-data histograms.
|
|
368
|
+
xlim, ylim
|
|
369
|
+
Axis limits; ``ylim`` entries may be ``None`` to keep the automatic value.
|
|
370
|
+
xbreak
|
|
371
|
+
``(a, b)`` to cut the x axis: the range between ``a`` and ``b`` is
|
|
372
|
+
removed and the two remaining segments are drawn side by side with a
|
|
373
|
+
break mark (e.g. a peak and a far tail, or a sentinel region). Works
|
|
374
|
+
with ratio panels; not with ``ax=``.
|
|
375
|
+
legend
|
|
376
|
+
``False`` to suppress, or a matplotlib location string.
|
|
377
|
+
stats
|
|
378
|
+
Add a box with entries, mean and standard deviation per sample;
|
|
379
|
+
``True`` or a location string.
|
|
380
|
+
text
|
|
381
|
+
Extra text line(s) drawn with the experiment label.
|
|
382
|
+
style
|
|
383
|
+
:class:`~rootfig.model.Style`, an experiment name (``"ATLAS"``, ...),
|
|
384
|
+
or ``None`` for the neutral rootfig style.
|
|
385
|
+
figsize
|
|
386
|
+
Figure size in inches.
|
|
387
|
+
ax
|
|
388
|
+
Draw into existing axes (``Axes`` or ``(main, ratio)`` pair).
|
|
389
|
+
nonfinite
|
|
390
|
+
``"drop"`` (warn) or ``"error"`` for ``nan``/``inf`` values.
|
|
391
|
+
save
|
|
392
|
+
Path to save the figure to (also returned in the :class:`Plot`).
|
|
393
|
+
|
|
394
|
+
Returns
|
|
395
|
+
-------
|
|
396
|
+
Plot
|
|
397
|
+
The figure, axes, histograms and ratios.
|
|
398
|
+
"""
|
|
399
|
+
samples = as_samples(data, tree=tree, labels=label)
|
|
400
|
+
if observed is not None:
|
|
401
|
+
observed_samples = [
|
|
402
|
+
s if s.is_data else s.with_(is_data=True) for s in as_samples(observed, tree=tree)
|
|
403
|
+
]
|
|
404
|
+
samples = [*samples, *observed_samples]
|
|
405
|
+
var = as_variable(variable, bins=bins, range=range, label=xlabel, unit=unit)
|
|
406
|
+
hists = build_histograms(
|
|
407
|
+
samples, var, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
408
|
+
)
|
|
409
|
+
resolved_style = _style_for(style, text, lumi)
|
|
410
|
+
return plot_histograms(
|
|
411
|
+
hists,
|
|
412
|
+
variable=var,
|
|
413
|
+
ylabel=ylabel,
|
|
414
|
+
title=title,
|
|
415
|
+
normalize=normalize,
|
|
416
|
+
stack=stack,
|
|
417
|
+
ratio=ratio,
|
|
418
|
+
ratio_ylim=ratio_ylim,
|
|
419
|
+
ratio_label=ratio_label,
|
|
420
|
+
ratio_uncertainty=ratio_uncertainty,
|
|
421
|
+
logx=logx,
|
|
422
|
+
logy=logy,
|
|
423
|
+
flow=flow,
|
|
424
|
+
histtype=histtype,
|
|
425
|
+
errorbars=errorbars,
|
|
426
|
+
xlim=xlim,
|
|
427
|
+
ylim=ylim,
|
|
428
|
+
xbreak=xbreak,
|
|
429
|
+
legend=legend,
|
|
430
|
+
stats=stats,
|
|
431
|
+
style=resolved_style,
|
|
432
|
+
figsize=figsize,
|
|
433
|
+
ax=ax,
|
|
434
|
+
save=save,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def plot_histograms(
|
|
439
|
+
hists: Sequence[Histogram | Hist],
|
|
440
|
+
*,
|
|
441
|
+
variable: Variable | None = None,
|
|
442
|
+
labels: Sequence[str] | None = None,
|
|
443
|
+
xlabel: str | None = None,
|
|
444
|
+
ylabel: str | None = None,
|
|
445
|
+
title: str | None = None,
|
|
446
|
+
normalize: NormalizeSpec = None,
|
|
447
|
+
stack: bool = False,
|
|
448
|
+
ratio: RatioSpec = False,
|
|
449
|
+
ratio_ylim: tuple[float, float] | None = None,
|
|
450
|
+
ratio_label: str | None = None,
|
|
451
|
+
ratio_uncertainty: RatioUncertainty | None = None,
|
|
452
|
+
logx: bool | None = None,
|
|
453
|
+
logy: bool = False,
|
|
454
|
+
flow: FlowSpec = "hint",
|
|
455
|
+
histtype: HistType | None = None,
|
|
456
|
+
errorbars: bool | None = None,
|
|
457
|
+
xlim: tuple[float, float] | None = None,
|
|
458
|
+
ylim: tuple[float | None, float | None] | None = None,
|
|
459
|
+
xbreak: tuple[float, float] | None = None,
|
|
460
|
+
legend: bool | str | None = None,
|
|
461
|
+
stats: bool | str = False,
|
|
462
|
+
style: StyleLike = None,
|
|
463
|
+
figsize: tuple[float, float] | None = None,
|
|
464
|
+
ax: AxesLike = None,
|
|
465
|
+
save: str | None = None,
|
|
466
|
+
) -> Plot:
|
|
467
|
+
"""Draw already-filled histograms (``hist.Hist`` or :class:`~rootfig.histograms.Histogram`).
|
|
468
|
+
|
|
469
|
+
Accepts the same drawing options as :func:`plot`. Plain ``hist.Hist``
|
|
470
|
+
objects are labelled from ``labels`` (or numbered) and converted to
|
|
471
|
+
``Weight`` storage if they have a plain count storage; mark data by passing
|
|
472
|
+
:class:`~rootfig.histograms.Histogram` objects with ``is_data=True``.
|
|
473
|
+
Overlaid histograms may have different binnings; stacks, ratio panels and
|
|
474
|
+
``flow="show"`` need identical bin edges.
|
|
475
|
+
"""
|
|
476
|
+
if logx is None:
|
|
477
|
+
logx = variable.log if variable is not None else False
|
|
478
|
+
histograms_ = _wrap_hists(hists, labels)
|
|
479
|
+
if not histograms_:
|
|
480
|
+
msg = "no histograms to draw"
|
|
481
|
+
raise ValueError(msg)
|
|
482
|
+
if any(h.ndim != 1 for h in histograms_):
|
|
483
|
+
msg = "plot_histograms() draws one-dimensional histograms; use plot2d() for 2D"
|
|
484
|
+
raise ValueError(msg)
|
|
485
|
+
if normalize is not None and normalize is not False:
|
|
486
|
+
histograms_ = [_normalize_for_plot(h, normalize) for h in histograms_]
|
|
487
|
+
resolved_style = as_style(style)
|
|
488
|
+
if legend is not None:
|
|
489
|
+
resolved_style = resolved_style.with_(legend=legend)
|
|
490
|
+
|
|
491
|
+
# The y label quotes the bin width of the histogram as filled, before flow bins are added.
|
|
492
|
+
label_widths = histograms_[0].widths
|
|
493
|
+
flow_shown = (False, False)
|
|
494
|
+
if flow == "show":
|
|
495
|
+
if xbreak is not None and xlim is None:
|
|
496
|
+
msg = "xbreak cannot be combined with flow='show'; pass xlim as well"
|
|
497
|
+
raise ValueError(msg)
|
|
498
|
+
# Done here rather than in mplhep so every histogram, the ratio panel and the
|
|
499
|
+
# x range agree on the extra bins (mplhep adds them per histogram).
|
|
500
|
+
histograms_, flow_shown = show_flow_bins(histograms_)
|
|
501
|
+
flow = "none"
|
|
502
|
+
elif flow == "sum":
|
|
503
|
+
# Fold once, up front, so ratios, bands and limits see the same bins as the drawing.
|
|
504
|
+
histograms_ = fold_flow_bins(histograms_)
|
|
505
|
+
flow = "none"
|
|
506
|
+
|
|
507
|
+
reference_hist = histograms_[0]
|
|
508
|
+
outer: tuple[float, float] | None = xlim or (
|
|
509
|
+
float(reference_hist.edges[0]),
|
|
510
|
+
float(reference_hist.edges[-1]),
|
|
511
|
+
)
|
|
512
|
+
if logx and outer is not None and outer[0] <= 0:
|
|
513
|
+
positive = reference_hist.edges[reference_hist.edges > 0]
|
|
514
|
+
outer = (float(positive[0]), outer[1]) if positive.size else None
|
|
515
|
+
segments = None
|
|
516
|
+
break_widths = None
|
|
517
|
+
if xbreak is not None:
|
|
518
|
+
assert outer is not None
|
|
519
|
+
left_range, right_range, break_widths = break_segments(outer, xbreak, logx=logx)
|
|
520
|
+
segments = (left_range, right_range)
|
|
521
|
+
|
|
522
|
+
with style_context(resolved_style) as st:
|
|
523
|
+
want_ratio = bool(ratio)
|
|
524
|
+
layout = make_figure(
|
|
525
|
+
st, ratio=want_ratio, ax=ax, figsize=figsize, break_widths=break_widths
|
|
526
|
+
)
|
|
527
|
+
drawn = None
|
|
528
|
+
for axis in layout.main_axes:
|
|
529
|
+
drawn = draw_histograms(
|
|
530
|
+
histograms_,
|
|
531
|
+
axis,
|
|
532
|
+
style=st,
|
|
533
|
+
stack=stack,
|
|
534
|
+
histtype=histtype,
|
|
535
|
+
errorbars=errorbars,
|
|
536
|
+
flow=flow,
|
|
537
|
+
)
|
|
538
|
+
assert drawn is not None
|
|
539
|
+
color_of = dict(zip(map(id, histograms_), drawn.histogram_colors, strict=True))
|
|
540
|
+
has_data = any(h.is_data for h in histograms_)
|
|
541
|
+
add_experiment_label(layout.main, st, has_data=has_data)
|
|
542
|
+
|
|
543
|
+
per_object = any(h.stats is not None and h.stats.per_object for h in histograms_)
|
|
544
|
+
unit = variable.unit if variable is not None else None
|
|
545
|
+
y_label = ylabel or ylabel_for(
|
|
546
|
+
normalization=histograms_[0].normalization,
|
|
547
|
+
unit=unit,
|
|
548
|
+
widths=label_widths,
|
|
549
|
+
per_object=per_object,
|
|
550
|
+
)
|
|
551
|
+
x_label = xlabel or (variable.axis_label if variable is not None else None)
|
|
552
|
+
if x_label is None:
|
|
553
|
+
x_label = reference_hist.axis.label or ""
|
|
554
|
+
data_low = drawn.ymin_positive if logy else drawn.ymin
|
|
555
|
+
for index, axis in enumerate(layout.main_axes):
|
|
556
|
+
finish_axes(
|
|
557
|
+
axis,
|
|
558
|
+
data_range=(data_low, drawn.ymax),
|
|
559
|
+
xlabel=None,
|
|
560
|
+
ylabel=y_label if index == 0 else None,
|
|
561
|
+
xlim=outer,
|
|
562
|
+
ylim=ylim,
|
|
563
|
+
logx=logx,
|
|
564
|
+
logy=logy,
|
|
565
|
+
)
|
|
566
|
+
# mplhep labels the axis from the hist; the label goes on one axes only
|
|
567
|
+
axis.set_xlabel("")
|
|
568
|
+
if title:
|
|
569
|
+
layout.main.set_title(title)
|
|
570
|
+
legend_loc = "upper right" if (stats and st.legend is True) else None
|
|
571
|
+
legend_artist = add_legend(layout.legend_axes, st, loc=legend_loc)
|
|
572
|
+
if stats:
|
|
573
|
+
add_stats_box(
|
|
574
|
+
layout.legend_axes,
|
|
575
|
+
histograms_,
|
|
576
|
+
loc=stats if isinstance(stats, str) else "auto",
|
|
577
|
+
colors=drawn.colors,
|
|
578
|
+
legend=legend_artist,
|
|
579
|
+
)
|
|
580
|
+
if ylim is None or ylim[1] is None:
|
|
581
|
+
# keep legend, labels and text boxes clear of the histograms
|
|
582
|
+
floating = legend_location(st) == "best" and legend_loc is None
|
|
583
|
+
obstacles = overlay_artists(layout.legend_axes, None if floating else legend_artist)
|
|
584
|
+
if layout.legend_axes is not layout.main:
|
|
585
|
+
obstacles += overlay_artists(layout.main, None)
|
|
586
|
+
env_edges, env_heights = envelope(histograms_, stack=stack)
|
|
587
|
+
raise_ylim_above(
|
|
588
|
+
layout.main_axes,
|
|
589
|
+
obstacles,
|
|
590
|
+
edges=env_edges,
|
|
591
|
+
heights=env_heights,
|
|
592
|
+
logy=logy,
|
|
593
|
+
floating=[legend_artist] if floating and legend_artist is not None else [],
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
ratios = []
|
|
597
|
+
significance_spec = _significance_spec(ratio)
|
|
598
|
+
if layout.ratio is not None and significance_spec is not None:
|
|
599
|
+
kind, signal_label = significance_spec
|
|
600
|
+
signal_h, background_h = _significance_setup(histograms_, signal_label)
|
|
601
|
+
sig_result = significance(signal_h.hist, background_h.hist, kind=kind)
|
|
602
|
+
ratios = [sig_result]
|
|
603
|
+
for index, axis in enumerate(layout.ratio_axes):
|
|
604
|
+
draw_significance_panel(
|
|
605
|
+
sig_result,
|
|
606
|
+
axis,
|
|
607
|
+
kind=kind,
|
|
608
|
+
color=drawn.colors.get(signal_h.label),
|
|
609
|
+
ylim=ratio_ylim,
|
|
610
|
+
ylabel=ratio_label if index == 0 else "",
|
|
611
|
+
)
|
|
612
|
+
if logx:
|
|
613
|
+
axis.set_xscale("log")
|
|
614
|
+
if outer is not None:
|
|
615
|
+
axis.set_xlim(*outer)
|
|
616
|
+
elif layout.ratio is not None:
|
|
617
|
+
assert not isinstance(ratio, tuple) # tuples are significance specs, handled above
|
|
618
|
+
numerators, reference, uncertainty = _ratio_setup(
|
|
619
|
+
histograms_, ratio, stack=stack, uncertainty=ratio_uncertainty
|
|
620
|
+
)
|
|
621
|
+
for index, axis in enumerate(layout.ratio_axes):
|
|
622
|
+
ratios = draw_ratio_panel(
|
|
623
|
+
numerators,
|
|
624
|
+
reference,
|
|
625
|
+
axis,
|
|
626
|
+
style=st,
|
|
627
|
+
uncertainty=uncertainty,
|
|
628
|
+
colors=[color_of.get(id(h), h.color or "black") for h in numerators],
|
|
629
|
+
ylim=ratio_ylim,
|
|
630
|
+
ylabel=ratio_label if index == 0 else "",
|
|
631
|
+
)
|
|
632
|
+
if logx:
|
|
633
|
+
axis.set_xscale("log")
|
|
634
|
+
if outer is not None:
|
|
635
|
+
axis.set_xlim(*outer)
|
|
636
|
+
layout.xlabel_axes.set_xlabel(x_label, loc="right")
|
|
637
|
+
if any(flow_shown):
|
|
638
|
+
label_flow_bins(
|
|
639
|
+
layout.xlabel_axes, reference_hist.edges, under=flow_shown[0], over=flow_shown[1]
|
|
640
|
+
)
|
|
641
|
+
if segments is not None:
|
|
642
|
+
assert layout.main_right is not None
|
|
643
|
+
apply_xbreak(layout.main, layout.main_right, *segments)
|
|
644
|
+
if layout.ratio is not None and layout.ratio_right is not None:
|
|
645
|
+
apply_xbreak(layout.ratio, layout.ratio_right, *segments)
|
|
646
|
+
|
|
647
|
+
result = Plot(
|
|
648
|
+
fig=layout.fig,
|
|
649
|
+
ax=layout.main,
|
|
650
|
+
ratio_ax=layout.ratio,
|
|
651
|
+
ax_right=layout.main_right,
|
|
652
|
+
ratio_ax_right=layout.ratio_right,
|
|
653
|
+
histograms=list(histograms_),
|
|
654
|
+
ratios=ratios,
|
|
655
|
+
variable=variable,
|
|
656
|
+
)
|
|
657
|
+
if save:
|
|
658
|
+
result.save(save)
|
|
659
|
+
return result
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# --------------------------------------------------------------------------------------
|
|
663
|
+
# 2D plots
|
|
664
|
+
# --------------------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def plot2d(
|
|
668
|
+
data: Any,
|
|
669
|
+
x: str | Variable,
|
|
670
|
+
y: str | Variable,
|
|
671
|
+
*,
|
|
672
|
+
tree: str | None = None,
|
|
673
|
+
selection: CutLike | None = None,
|
|
674
|
+
weight: str | None = None,
|
|
675
|
+
lumi: float | str | None = None,
|
|
676
|
+
bins: Bins | tuple[Bins, Bins] | None = None,
|
|
677
|
+
normalize: NormalizeSpec = None,
|
|
678
|
+
logz: bool = False,
|
|
679
|
+
logx: bool | None = None,
|
|
680
|
+
logy: bool | None = None,
|
|
681
|
+
cmap: str | Any = "viridis",
|
|
682
|
+
colorbar: bool = True,
|
|
683
|
+
zlabel: str | None = None,
|
|
684
|
+
title: str | None = None,
|
|
685
|
+
text: str | Sequence[str] | None = None,
|
|
686
|
+
style: StyleLike = None,
|
|
687
|
+
figsize: tuple[float, float] | None = None,
|
|
688
|
+
ax: AxesLike = None,
|
|
689
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
690
|
+
save: str | None = None,
|
|
691
|
+
) -> Plot:
|
|
692
|
+
"""Draw a two-dimensional histogram of ``y`` versus ``x`` for one sample.
|
|
693
|
+
|
|
694
|
+
``x`` and ``y`` must have the same structure (both per-event, or both
|
|
695
|
+
per-object from the same collection). ``bins`` applies to both axes unless
|
|
696
|
+
it is a pair of binning specifications, one per axis; per-axis ranges,
|
|
697
|
+
labels and logarithmic scales (``logx``/``logy`` default to the variables'
|
|
698
|
+
``log`` flags) are best given through :class:`~rootfig.model.Variable`
|
|
699
|
+
objects.
|
|
700
|
+
"""
|
|
701
|
+
sample = _single_sample(data, tree=tree)
|
|
702
|
+
x_bins, y_bins = _split_bins(bins)
|
|
703
|
+
var_x = as_variable(x, bins=x_bins)
|
|
704
|
+
var_y = as_variable(y, bins=y_bins)
|
|
705
|
+
logx = var_x.log if logx is None else logx
|
|
706
|
+
logy = var_y.log if logy is None else logy
|
|
707
|
+
[histogram_] = build_histograms_2d(
|
|
708
|
+
[sample], var_x, var_y, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
709
|
+
)
|
|
710
|
+
if normalize is not None and normalize is not False:
|
|
711
|
+
histogram_ = _normalize_for_plot(histogram_, normalize)
|
|
712
|
+
resolved_style = _style_for(style, text, lumi)
|
|
713
|
+
with style_context(resolved_style) as st:
|
|
714
|
+
default_size = st.figsize or figsize
|
|
715
|
+
if default_size is None:
|
|
716
|
+
width, height = plt.rcParams["figure.figsize"]
|
|
717
|
+
default_size = (width * 1.15, height)
|
|
718
|
+
layout = make_figure(st, ratio=False, ax=ax, figsize=default_size)
|
|
719
|
+
fig, main_ax = layout.fig, layout.main
|
|
720
|
+
draw_hist2d(
|
|
721
|
+
histogram_,
|
|
722
|
+
main_ax,
|
|
723
|
+
logz=logz,
|
|
724
|
+
cmap=cmap,
|
|
725
|
+
colorbar=colorbar,
|
|
726
|
+
zlabel=zlabel or (histogram_.normalization or "Events"),
|
|
727
|
+
)
|
|
728
|
+
if logx:
|
|
729
|
+
main_ax.set_xscale("log")
|
|
730
|
+
if logy:
|
|
731
|
+
main_ax.set_yscale("log")
|
|
732
|
+
if title:
|
|
733
|
+
main_ax.set_title(title)
|
|
734
|
+
add_experiment_label(main_ax, st, has_data=sample.is_data)
|
|
735
|
+
result = Plot(fig=fig, ax=main_ax, histograms=[histogram_], variable=var_x)
|
|
736
|
+
if save:
|
|
737
|
+
result.save(save)
|
|
738
|
+
return result
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# --------------------------------------------------------------------------------------
|
|
742
|
+
# Statistics
|
|
743
|
+
# --------------------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@dataclass(frozen=True)
|
|
747
|
+
class SummaryTable:
|
|
748
|
+
"""Summary statistics for several variables and samples.
|
|
749
|
+
|
|
750
|
+
``str(table)`` gives an aligned text table; :meth:`get` returns a single
|
|
751
|
+
:class:`~rootfig.histograms.Summary`.
|
|
752
|
+
"""
|
|
753
|
+
|
|
754
|
+
rows: tuple[tuple[str, str, Summary], ...]
|
|
755
|
+
"""``(sample label, variable expression, summary)`` triples."""
|
|
756
|
+
|
|
757
|
+
def get(self, variable: str, sample: str | None = None) -> Summary:
|
|
758
|
+
"""Return the summary for ``variable`` (and ``sample``, if several)."""
|
|
759
|
+
matches = [
|
|
760
|
+
s
|
|
761
|
+
for label, var, s in self.rows
|
|
762
|
+
if var == variable and (sample is None or label == sample)
|
|
763
|
+
]
|
|
764
|
+
if not matches:
|
|
765
|
+
msg = f"no summary for variable {variable!r}" + (
|
|
766
|
+
f" and sample {sample!r}" if sample else ""
|
|
767
|
+
)
|
|
768
|
+
raise KeyError(msg)
|
|
769
|
+
if len(matches) > 1:
|
|
770
|
+
msg = f"several samples have variable {variable!r}; pass sample=..."
|
|
771
|
+
raise KeyError(msg)
|
|
772
|
+
return matches[0]
|
|
773
|
+
|
|
774
|
+
@property
|
|
775
|
+
def samples(self) -> list[str]:
|
|
776
|
+
"""Distinct sample labels in order of appearance."""
|
|
777
|
+
return list(dict.fromkeys(label for label, _, _ in self.rows))
|
|
778
|
+
|
|
779
|
+
@property
|
|
780
|
+
def variables(self) -> list[str]:
|
|
781
|
+
"""Distinct variable expressions in order of appearance."""
|
|
782
|
+
return list(dict.fromkeys(var for _, var, _ in self.rows))
|
|
783
|
+
|
|
784
|
+
def __str__(self) -> str:
|
|
785
|
+
multi = len(self.samples) > 1
|
|
786
|
+
entries = [(f"{label}: {var}" if multi else var, s) for label, var, s in self.rows]
|
|
787
|
+
return describe_table(entries)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def summarize(
|
|
791
|
+
data: Any,
|
|
792
|
+
variables: str | Variable | Sequence[str | Variable],
|
|
793
|
+
*,
|
|
794
|
+
tree: str | None = None,
|
|
795
|
+
selection: CutLike | None = None,
|
|
796
|
+
weight: str | None = None,
|
|
797
|
+
lumi: float | str | None = None,
|
|
798
|
+
label: str | Sequence[str] | None = None,
|
|
799
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
800
|
+
) -> SummaryTable:
|
|
801
|
+
"""Compute entries, mean, standard deviation, skewness, ... for variables and samples.
|
|
802
|
+
|
|
803
|
+
Examples
|
|
804
|
+
--------
|
|
805
|
+
>>> table = rf.summarize(
|
|
806
|
+
... "events.root", ["MET", "Muon_pt"], tree="events", selection="nMuon > 0"
|
|
807
|
+
... ) # doctest: +SKIP
|
|
808
|
+
>>> print(table) # doctest: +SKIP
|
|
809
|
+
>>> table.get("MET").mean # doctest: +SKIP
|
|
810
|
+
"""
|
|
811
|
+
samples = as_samples(data, tree=tree, labels=label)
|
|
812
|
+
var_list = [variables] if isinstance(variables, str | Variable) else list(variables)
|
|
813
|
+
rows: list[tuple[str, str, Summary]] = []
|
|
814
|
+
for sample in samples:
|
|
815
|
+
for var in var_list:
|
|
816
|
+
columns = load_columns(
|
|
817
|
+
sample, [var], selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
818
|
+
)
|
|
819
|
+
rows.append((sample.label, as_variable(var).expression, summarize_columns(columns)))
|
|
820
|
+
return SummaryTable(tuple(rows))
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def correlation(
|
|
824
|
+
data: Any,
|
|
825
|
+
variables: Sequence[str | Variable],
|
|
826
|
+
*,
|
|
827
|
+
tree: str | None = None,
|
|
828
|
+
selection: CutLike | None = None,
|
|
829
|
+
weight: str | None = None,
|
|
830
|
+
lumi: float | str | None = None,
|
|
831
|
+
labels: Sequence[str] | None = None,
|
|
832
|
+
percent: bool = False,
|
|
833
|
+
cmap: str | Any = "RdBu_r",
|
|
834
|
+
annotate: bool = True,
|
|
835
|
+
title: str | None = None,
|
|
836
|
+
style: StyleLike = None,
|
|
837
|
+
figsize: tuple[float, float] | None = None,
|
|
838
|
+
ax: AxesLike = None,
|
|
839
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
840
|
+
save: str | None = None,
|
|
841
|
+
) -> Plot:
|
|
842
|
+
"""Draw the correlation matrix of several variables for one sample.
|
|
843
|
+
|
|
844
|
+
All variables must share the same structure (all per-event, or all
|
|
845
|
+
per-object from one collection). The matrix is available as
|
|
846
|
+
``Plot.matrix``.
|
|
847
|
+
"""
|
|
848
|
+
sample = _single_sample(data, tree=tree)
|
|
849
|
+
var_list = [as_variable(v) for v in variables]
|
|
850
|
+
if len(var_list) < 2:
|
|
851
|
+
msg = "correlation() needs at least two variables"
|
|
852
|
+
raise SelectionError(msg)
|
|
853
|
+
columns: Columns = load_columns(
|
|
854
|
+
sample, var_list, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
855
|
+
)
|
|
856
|
+
matrix: FloatArray = correlation_matrix(columns)
|
|
857
|
+
tick_labels = (
|
|
858
|
+
list(labels) if labels is not None else [v.label or v.expression for v in var_list]
|
|
859
|
+
)
|
|
860
|
+
with style_context(style) as st:
|
|
861
|
+
size = figsize or st.figsize
|
|
862
|
+
if size is None:
|
|
863
|
+
side = max(4.5, 0.75 * len(var_list) + 2.5)
|
|
864
|
+
size = (side * 1.15, side)
|
|
865
|
+
layout = make_figure(st, ratio=False, ax=ax, figsize=size)
|
|
866
|
+
fig, main_ax = layout.fig, layout.main
|
|
867
|
+
draw_correlation(
|
|
868
|
+
matrix, tick_labels, main_ax, cmap=cmap, annotate=annotate, percent=percent
|
|
869
|
+
)
|
|
870
|
+
main_ax.set_title(title if title is not None else f"{sample.label}: correlation")
|
|
871
|
+
result = Plot(fig=fig, ax=main_ax, matrix=matrix)
|
|
872
|
+
if save:
|
|
873
|
+
result.save(save)
|
|
874
|
+
return result
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
# --------------------------------------------------------------------------------------
|
|
878
|
+
# Cut flows, efficiencies and profiles
|
|
879
|
+
# --------------------------------------------------------------------------------------
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def cutflow(
|
|
883
|
+
data: Any,
|
|
884
|
+
cuts: Sequence[CutLike],
|
|
885
|
+
*,
|
|
886
|
+
tree: str | None = None,
|
|
887
|
+
weight: str | None = None,
|
|
888
|
+
lumi: float | str | None = None,
|
|
889
|
+
label: str | Sequence[str] | None = None,
|
|
890
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
891
|
+
) -> CutflowTable:
|
|
892
|
+
"""Count events and weighted yields after each successive cut, per sample.
|
|
893
|
+
|
|
894
|
+
The first row holds all events (after the sample's own selection, if any);
|
|
895
|
+
every further row applies one more cut. Per-object cuts pass an event when
|
|
896
|
+
any object passes. ``weight``, ``lumi`` and ``nonfinite`` work as in
|
|
897
|
+
:func:`plot`: events with a ``nan``/``inf`` weight are excluded from all
|
|
898
|
+
steps with a warning, or raise for ``nonfinite="error"``.
|
|
899
|
+
|
|
900
|
+
Examples
|
|
901
|
+
--------
|
|
902
|
+
>>> table = rf.cutflow(
|
|
903
|
+
... [sig, bkg], ["nMuon >= 2", rf.Cut("MET > 50", label="MET"), "any(Jet_btag > 0.8)"]
|
|
904
|
+
... ) # doctest: +SKIP
|
|
905
|
+
>>> print(table) # doctest: +SKIP
|
|
906
|
+
>>> table.get("Signal").efficiencies # doctest: +SKIP
|
|
907
|
+
"""
|
|
908
|
+
samples = as_samples(data, tree=tree, labels=label)
|
|
909
|
+
return CutflowTable(
|
|
910
|
+
tuple(cutflow_of(s, cuts, weight=weight, lumi=lumi, nonfinite=nonfinite) for s in samples)
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def efficiency(
|
|
915
|
+
data: Any,
|
|
916
|
+
variable: str | Variable,
|
|
917
|
+
*,
|
|
918
|
+
passed: CutLike,
|
|
919
|
+
tree: str | None = None,
|
|
920
|
+
selection: CutLike | None = None,
|
|
921
|
+
weight: str | None = None,
|
|
922
|
+
lumi: float | str | None = None,
|
|
923
|
+
bins: Bins | None = None,
|
|
924
|
+
range: RangeSpec = None,
|
|
925
|
+
label: str | Sequence[str] | None = None,
|
|
926
|
+
xlabel: str | None = None,
|
|
927
|
+
ylabel: str | None = None,
|
|
928
|
+
unit: str | None = None,
|
|
929
|
+
title: str | None = None,
|
|
930
|
+
logx: bool | None = None,
|
|
931
|
+
xlim: tuple[float, float] | None = None,
|
|
932
|
+
ylim: tuple[float | None, float | None] | None = None,
|
|
933
|
+
legend: bool | str | None = None,
|
|
934
|
+
text: str | Sequence[str] | None = None,
|
|
935
|
+
style: StyleLike = None,
|
|
936
|
+
figsize: tuple[float, float] | None = None,
|
|
937
|
+
ax: AxesLike = None,
|
|
938
|
+
z: float = 1.0,
|
|
939
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
940
|
+
save: str | None = None,
|
|
941
|
+
) -> Plot:
|
|
942
|
+
"""Plot the fraction of entries passing ``passed`` as a function of ``variable``.
|
|
943
|
+
|
|
944
|
+
For every sample two histograms are filled with the same binning, all
|
|
945
|
+
entries satisfying ``selection`` (the denominator) and those also
|
|
946
|
+
satisfying ``passed`` (the numerator); the ratio is drawn as points with
|
|
947
|
+
Wilson score intervals (``z`` standard deviations, effective entries for
|
|
948
|
+
weighted samples; see :func:`rootfig.histograms.efficiency` for the
|
|
949
|
+
treatment of negative weights). The :class:`~rootfig.histograms.Efficiency`
|
|
950
|
+
objects are returned in ``Plot.efficiencies``.
|
|
951
|
+
|
|
952
|
+
Examples
|
|
953
|
+
--------
|
|
954
|
+
>>> rf.efficiency(
|
|
955
|
+
... "reco.root", "TrueMuon_pt", passed="TrueMuon_matched", bins=(20, 0, 100)
|
|
956
|
+
... ) # doctest: +SKIP
|
|
957
|
+
"""
|
|
958
|
+
samples = as_samples(data, tree=tree, labels=label)
|
|
959
|
+
var = as_variable(variable, bins=bins, range=range, label=xlabel, unit=unit)
|
|
960
|
+
logx = var.log if logx is None else logx
|
|
961
|
+
totals = build_histograms(
|
|
962
|
+
samples, var, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
963
|
+
)
|
|
964
|
+
fixed = var.with_(bins=totals[0].axis) # same binning for the numerators
|
|
965
|
+
pass_cut = as_cut(passed)
|
|
966
|
+
if pass_cut is None:
|
|
967
|
+
msg = "efficiency() needs a 'passed' selection"
|
|
968
|
+
raise ValueError(msg)
|
|
969
|
+
base = as_cut(selection)
|
|
970
|
+
numerator_cut = pass_cut if base is None else base & pass_cut
|
|
971
|
+
passes = build_histograms(
|
|
972
|
+
samples, fixed, selection=numerator_cut, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
973
|
+
)
|
|
974
|
+
efficiencies = [
|
|
975
|
+
efficiency_of(p.hist, t.hist, z=z, label=sample.label)
|
|
976
|
+
for p, t, sample in zip(passes, totals, samples, strict=True)
|
|
977
|
+
]
|
|
978
|
+
resolved_style = _style_for(style, text, lumi)
|
|
979
|
+
if legend is not None:
|
|
980
|
+
resolved_style = resolved_style.with_(legend=legend)
|
|
981
|
+
with style_context(resolved_style) as st:
|
|
982
|
+
layout = make_figure(st, ratio=False, ax=ax, figsize=figsize)
|
|
983
|
+
cycle = iter(color_cycle(len(samples), st))
|
|
984
|
+
colors = [s.color or next(cycle) for s in samples]
|
|
985
|
+
low, high = draw_efficiencies(efficiencies, layout.main, style=st, colors=colors)
|
|
986
|
+
outer = xlim or (float(fixed.bins.edges[0]), float(fixed.bins.edges[-1])) # type: ignore[union-attr]
|
|
987
|
+
finish_axes(
|
|
988
|
+
layout.main,
|
|
989
|
+
data_range=(low, high),
|
|
990
|
+
xlabel=fixed.axis_label,
|
|
991
|
+
ylabel=ylabel or "Efficiency",
|
|
992
|
+
xlim=outer,
|
|
993
|
+
ylim=ylim,
|
|
994
|
+
logx=logx,
|
|
995
|
+
logy=False,
|
|
996
|
+
headroom=1.08,
|
|
997
|
+
)
|
|
998
|
+
if title:
|
|
999
|
+
layout.main.set_title(title)
|
|
1000
|
+
add_experiment_label(layout.main, st, has_data=any(s.is_data for s in samples))
|
|
1001
|
+
legend_artist = add_legend(layout.main, st)
|
|
1002
|
+
if ylim is None or ylim[1] is None:
|
|
1003
|
+
edges = efficiencies[0].edges
|
|
1004
|
+
heights = np.nanmax(
|
|
1005
|
+
np.vstack([np.nan_to_num(e.upper, nan=0.0) for e in efficiencies]), axis=0
|
|
1006
|
+
)
|
|
1007
|
+
floating = legend_location(st) == "best"
|
|
1008
|
+
raise_ylim_above(
|
|
1009
|
+
[layout.main],
|
|
1010
|
+
overlay_artists(layout.main, None if floating else legend_artist),
|
|
1011
|
+
edges=edges,
|
|
1012
|
+
heights=heights,
|
|
1013
|
+
logy=False,
|
|
1014
|
+
floating=[legend_artist] if floating and legend_artist is not None else [],
|
|
1015
|
+
)
|
|
1016
|
+
result = Plot(
|
|
1017
|
+
fig=layout.fig,
|
|
1018
|
+
ax=layout.main,
|
|
1019
|
+
histograms=list(passes),
|
|
1020
|
+
variable=fixed,
|
|
1021
|
+
efficiencies=efficiencies,
|
|
1022
|
+
)
|
|
1023
|
+
if save:
|
|
1024
|
+
result.save(save)
|
|
1025
|
+
return result
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def profile(
|
|
1029
|
+
data: Any,
|
|
1030
|
+
x: str | Variable,
|
|
1031
|
+
y: str | Variable,
|
|
1032
|
+
*,
|
|
1033
|
+
statistic: ProfileStatistic = "mean",
|
|
1034
|
+
tree: str | None = None,
|
|
1035
|
+
selection: CutLike | None = None,
|
|
1036
|
+
weight: str | None = None,
|
|
1037
|
+
lumi: float | str | None = None,
|
|
1038
|
+
bins: Bins | None = None,
|
|
1039
|
+
range: RangeSpec = None,
|
|
1040
|
+
label: str | Sequence[str] | None = None,
|
|
1041
|
+
xlabel: str | None = None,
|
|
1042
|
+
ylabel: str | None = None,
|
|
1043
|
+
unit: str | None = None,
|
|
1044
|
+
title: str | None = None,
|
|
1045
|
+
logx: bool | None = None,
|
|
1046
|
+
logy: bool | None = None,
|
|
1047
|
+
xlim: tuple[float, float] | None = None,
|
|
1048
|
+
ylim: tuple[float | None, float | None] | None = None,
|
|
1049
|
+
legend: bool | str | None = None,
|
|
1050
|
+
text: str | Sequence[str] | None = None,
|
|
1051
|
+
style: StyleLike = None,
|
|
1052
|
+
figsize: tuple[float, float] | None = None,
|
|
1053
|
+
ax: AxesLike = None,
|
|
1054
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
1055
|
+
save: str | None = None,
|
|
1056
|
+
) -> Plot:
|
|
1057
|
+
"""Plot the mean (or standard deviation) of ``y`` in bins of ``x``, per sample.
|
|
1058
|
+
|
|
1059
|
+
``statistic="mean"`` draws the weighted mean with its standard error, the
|
|
1060
|
+
profile histogram of ROOT; ``"std"`` draws the standard deviation with its
|
|
1061
|
+
error, the usual resolution-versus-variable plot (make ``y`` the residual,
|
|
1062
|
+
e.g. ``"(reco_pt - true_pt) / true_pt"``). ``x`` and ``y`` must have the same
|
|
1063
|
+
structure (both per-event or both per-object of one collection). ``xlabel``
|
|
1064
|
+
and ``unit`` describe the x axis; ``logx``/``logy`` default to the
|
|
1065
|
+
variables' ``log`` flags. With negative weights a bin whose weighted
|
|
1066
|
+
variance is negative has no standard deviation (``nan``). The
|
|
1067
|
+
:class:`~rootfig.histograms.Profile` objects are returned in ``Plot.profiles``.
|
|
1068
|
+
|
|
1069
|
+
Examples
|
|
1070
|
+
--------
|
|
1071
|
+
>>> rf.profile(
|
|
1072
|
+
... "reco.root",
|
|
1073
|
+
... "true_pt",
|
|
1074
|
+
... "(reco_pt - true_pt) / true_pt",
|
|
1075
|
+
... statistic="std",
|
|
1076
|
+
... bins=(20, 0, 100),
|
|
1077
|
+
... unit="GeV",
|
|
1078
|
+
... ) # doctest: +SKIP
|
|
1079
|
+
"""
|
|
1080
|
+
samples = as_samples(data, tree=tree, labels=label)
|
|
1081
|
+
var_x = as_variable(x, bins=bins, range=range, label=xlabel, unit=unit)
|
|
1082
|
+
var_y = as_variable(y)
|
|
1083
|
+
logx = var_x.log if logx is None else logx
|
|
1084
|
+
logy = var_y.log if logy is None else logy
|
|
1085
|
+
columns = [
|
|
1086
|
+
load_columns(
|
|
1087
|
+
s, [var_x, var_y], selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
|
|
1088
|
+
)
|
|
1089
|
+
for s in samples
|
|
1090
|
+
]
|
|
1091
|
+
axis = resolve_axis(var_x, [c.arrays[0] for c in columns], name=var_x.safe_name)
|
|
1092
|
+
edges = np.asarray(axis.edges, dtype=float)
|
|
1093
|
+
profiles = [
|
|
1094
|
+
profile_of(
|
|
1095
|
+
c.arrays[0], c.arrays[1], edges, weights=c.weights, statistic=statistic, label=s.label
|
|
1096
|
+
)
|
|
1097
|
+
for c, s in zip(columns, samples, strict=True)
|
|
1098
|
+
]
|
|
1099
|
+
resolved_style = _style_for(style, text, lumi)
|
|
1100
|
+
if legend is not None:
|
|
1101
|
+
resolved_style = resolved_style.with_(legend=legend)
|
|
1102
|
+
if ylabel is None:
|
|
1103
|
+
ylabel = var_y.axis_label if statistic == "mean" else f"Std. dev. of {var_y.axis_label}"
|
|
1104
|
+
with style_context(resolved_style) as st:
|
|
1105
|
+
layout = make_figure(st, ratio=False, ax=ax, figsize=figsize)
|
|
1106
|
+
cycle = iter(color_cycle(len(samples), st))
|
|
1107
|
+
colors = [s.color or next(cycle) for s in samples]
|
|
1108
|
+
low, high = draw_profiles(profiles, layout.main, style=st, colors=colors)
|
|
1109
|
+
finish_axes(
|
|
1110
|
+
layout.main,
|
|
1111
|
+
data_range=(low, high),
|
|
1112
|
+
xlabel=var_x.with_(bins=axis).axis_label,
|
|
1113
|
+
ylabel=ylabel,
|
|
1114
|
+
xlim=xlim or (float(edges[0]), float(edges[-1])),
|
|
1115
|
+
ylim=ylim,
|
|
1116
|
+
logx=logx,
|
|
1117
|
+
logy=logy,
|
|
1118
|
+
headroom=1.25,
|
|
1119
|
+
ymin_linear="auto",
|
|
1120
|
+
)
|
|
1121
|
+
if title:
|
|
1122
|
+
layout.main.set_title(title)
|
|
1123
|
+
add_experiment_label(layout.main, st, has_data=any(s.is_data for s in samples))
|
|
1124
|
+
legend_artist = add_legend(layout.main, st)
|
|
1125
|
+
if ylim is None or ylim[1] is None:
|
|
1126
|
+
heights = np.nanmax(
|
|
1127
|
+
np.vstack(
|
|
1128
|
+
[
|
|
1129
|
+
np.nan_to_num(pr.values + np.nan_to_num(pr.errors), nan=-np.inf)
|
|
1130
|
+
for pr in profiles
|
|
1131
|
+
]
|
|
1132
|
+
),
|
|
1133
|
+
axis=0,
|
|
1134
|
+
)
|
|
1135
|
+
heights = np.where(np.isfinite(heights), heights, 0.0)
|
|
1136
|
+
floating = legend_location(st) == "best"
|
|
1137
|
+
raise_ylim_above(
|
|
1138
|
+
[layout.main],
|
|
1139
|
+
overlay_artists(layout.main, None if floating else legend_artist),
|
|
1140
|
+
edges=edges,
|
|
1141
|
+
heights=heights,
|
|
1142
|
+
logy=logy,
|
|
1143
|
+
floating=[legend_artist] if floating and legend_artist is not None else [],
|
|
1144
|
+
)
|
|
1145
|
+
result = Plot(fig=layout.fig, ax=layout.main, variable=var_x, profiles=profiles)
|
|
1146
|
+
if save:
|
|
1147
|
+
result.save(save)
|
|
1148
|
+
return result
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
# --------------------------------------------------------------------------------------
|
|
1152
|
+
# Helpers
|
|
1153
|
+
# --------------------------------------------------------------------------------------
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _single_sample(
|
|
1157
|
+
data: Any,
|
|
1158
|
+
*,
|
|
1159
|
+
tree: str | None,
|
|
1160
|
+
entry_start: int | None = None,
|
|
1161
|
+
entry_stop: int | None = None,
|
|
1162
|
+
) -> Sample:
|
|
1163
|
+
samples = as_samples(data, tree=tree, entry_start=entry_start, entry_stop=entry_stop)
|
|
1164
|
+
if len(samples) != 1:
|
|
1165
|
+
msg = f"expected a single sample, got {len(samples)}"
|
|
1166
|
+
raise SourceError(msg)
|
|
1167
|
+
return samples[0]
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _style_for(
|
|
1171
|
+
style: StyleLike, text: str | Sequence[str] | None, lumi: float | str | None = None
|
|
1172
|
+
) -> Any:
|
|
1173
|
+
"""Resolve ``style`` and add free text lines and the luminosity used for scaling."""
|
|
1174
|
+
resolved = as_style(style)
|
|
1175
|
+
if lumi is not None and resolved.lumi is None:
|
|
1176
|
+
resolved = resolved.with_(lumi=lumi)
|
|
1177
|
+
if text is None:
|
|
1178
|
+
return resolved
|
|
1179
|
+
existing = list(resolved.text_lines)
|
|
1180
|
+
extra = [text] if isinstance(text, str) else list(text)
|
|
1181
|
+
return resolved.with_(text=[*existing, *extra])
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _significance_spec(ratio: RatioSpec) -> tuple[SignificanceKind, str | None] | None:
|
|
1185
|
+
"""``(kind, signal label)`` when ``ratio`` asks for a significance panel, else ``None``."""
|
|
1186
|
+
if isinstance(ratio, tuple):
|
|
1187
|
+
kind, label = ratio
|
|
1188
|
+
if kind not in SIGNIFICANCE_KINDS:
|
|
1189
|
+
msg = f"ratio=({kind!r}, ...) must use one of {SIGNIFICANCE_KINDS}"
|
|
1190
|
+
raise ValueError(msg)
|
|
1191
|
+
return ("s/sqrt(b)" if kind == "significance" else kind, label) # type: ignore[return-value]
|
|
1192
|
+
if isinstance(ratio, str) and ratio in SIGNIFICANCE_KINDS:
|
|
1193
|
+
return ("s/sqrt(b)" if ratio == "significance" else ratio, None) # type: ignore[return-value]
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _significance_setup(
|
|
1198
|
+
hists: Sequence[Histogram], signal_label: str | None
|
|
1199
|
+
) -> tuple[Histogram, Histogram]:
|
|
1200
|
+
"""Pick the signal histogram and sum the other non-data ones into the background."""
|
|
1201
|
+
mc = [h for h in hists if not h.is_data]
|
|
1202
|
+
if len(mc) < 2:
|
|
1203
|
+
msg = "a significance panel needs at least two non-data histograms (signal and background)"
|
|
1204
|
+
raise ValueError(msg)
|
|
1205
|
+
if signal_label is None:
|
|
1206
|
+
signal = mc[-1]
|
|
1207
|
+
else:
|
|
1208
|
+
matches = [h for h in mc if h.label == signal_label]
|
|
1209
|
+
if not matches:
|
|
1210
|
+
msg = f"signal {signal_label!r} is not one of {[h.label for h in mc]}"
|
|
1211
|
+
raise ValueError(msg)
|
|
1212
|
+
signal = matches[0]
|
|
1213
|
+
others = [h for h in mc if h is not signal]
|
|
1214
|
+
total = others[0].hist.copy()
|
|
1215
|
+
for h in others[1:]:
|
|
1216
|
+
total = total + h.hist
|
|
1217
|
+
return signal, Histogram(total, label="Background", normalization=others[0].normalization)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
RatioSpec = bool | str | tuple[str, str]
|
|
1221
|
+
"""What ``ratio=`` accepts: a flag, a reference label, a significance kind, or (kind, signal)."""
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _wrap_hists(hists: Sequence[Histogram | Hist], labels: Sequence[str] | None) -> list[Histogram]:
|
|
1225
|
+
if labels is not None and len(labels) != len(hists):
|
|
1226
|
+
msg = f"got {len(labels)} labels for {len(hists)} histograms"
|
|
1227
|
+
raise ValueError(msg)
|
|
1228
|
+
wrapped: list[Histogram] = []
|
|
1229
|
+
for index, item in enumerate(hists):
|
|
1230
|
+
if isinstance(item, Histogram):
|
|
1231
|
+
wrapped.append(item if labels is None else item.with_(label=labels[index]))
|
|
1232
|
+
continue
|
|
1233
|
+
if labels is not None:
|
|
1234
|
+
label = labels[index]
|
|
1235
|
+
else:
|
|
1236
|
+
axis_name = item.axes[0].name if item.ndim == 1 else ""
|
|
1237
|
+
label = axis_name or f"hist {index + 1}"
|
|
1238
|
+
wrapped.append(Histogram(as_weight_storage(item), label=str(label)))
|
|
1239
|
+
return wrapped
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _normalize_for_plot(histogram_: Histogram, spec: NormalizeSpec) -> Histogram:
|
|
1243
|
+
if histogram_.normalization is not None:
|
|
1244
|
+
warnings.warn(
|
|
1245
|
+
f"histogram {histogram_.label!r} is already normalised ({histogram_.normalization}); "
|
|
1246
|
+
"normalising again",
|
|
1247
|
+
RootfigWarning,
|
|
1248
|
+
stacklevel=3,
|
|
1249
|
+
)
|
|
1250
|
+
return normalize_histogram(histogram_, spec)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _ratio_setup(
|
|
1254
|
+
hists: Sequence[Histogram],
|
|
1255
|
+
ratio: bool | str,
|
|
1256
|
+
*,
|
|
1257
|
+
stack: bool,
|
|
1258
|
+
uncertainty: RatioUncertainty | None,
|
|
1259
|
+
) -> tuple[list[Histogram], Histogram, RatioUncertainty]:
|
|
1260
|
+
data = [h for h in hists if h.is_data]
|
|
1261
|
+
mc = [h for h in hists if not h.is_data]
|
|
1262
|
+
if isinstance(ratio, str):
|
|
1263
|
+
matches = [h for h in hists if h.label == ratio]
|
|
1264
|
+
if not matches:
|
|
1265
|
+
msg = f"ratio reference {ratio!r} is not one of {[h.label for h in hists]}"
|
|
1266
|
+
raise ValueError(msg)
|
|
1267
|
+
reference = matches[0]
|
|
1268
|
+
numerators = [h for h in hists if h is not reference]
|
|
1269
|
+
default_uncertainty: RatioUncertainty = (
|
|
1270
|
+
"numerator" if data and reference in mc else "propagate"
|
|
1271
|
+
)
|
|
1272
|
+
return numerators, reference, uncertainty or default_uncertainty
|
|
1273
|
+
if stack and mc:
|
|
1274
|
+
total = mc[0].hist.copy()
|
|
1275
|
+
for h in mc[1:]:
|
|
1276
|
+
total = total + h.hist
|
|
1277
|
+
reference = Histogram(total, label="Total", normalization=mc[0].normalization)
|
|
1278
|
+
numerators = data if data else []
|
|
1279
|
+
if not numerators:
|
|
1280
|
+
msg = "ratio=True with stack=True needs an observed data sample (observed=...)"
|
|
1281
|
+
raise ValueError(msg)
|
|
1282
|
+
return numerators, reference, uncertainty or "numerator"
|
|
1283
|
+
if data and mc:
|
|
1284
|
+
return data, mc[0], uncertainty or "numerator"
|
|
1285
|
+
if len(hists) < 2:
|
|
1286
|
+
msg = "a ratio panel needs at least two histograms"
|
|
1287
|
+
raise ValueError(msg)
|
|
1288
|
+
return list(hists[1:]), hists[0], uncertainty or "propagate"
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _split_bins(bins: Bins | tuple[Bins, Bins] | None) -> tuple[Any, Any]:
|
|
1292
|
+
"""Interpret ``bins`` for two axes: a pair of specifications, or one spec for both."""
|
|
1293
|
+
if bins is None:
|
|
1294
|
+
return None, None
|
|
1295
|
+
if isinstance(bins, tuple) and len(bins) == 2:
|
|
1296
|
+
return bins[0], bins[1]
|
|
1297
|
+
return bins, bins
|