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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Filling ``Hist`` objects and the :class:`Histogram` wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import hist
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from rootfig._typing import FloatArray, Hist
|
|
13
|
+
from rootfig.histograms.stats import Summary
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from rootfig.model.binning import Axis
|
|
17
|
+
from rootfig.model.samples import HistType, Sample
|
|
18
|
+
from rootfig.selection import Columns
|
|
19
|
+
|
|
20
|
+
__all__ = ["Histogram", "as_weight_storage", "fill"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fill(axes: Sequence[Axis], columns: Columns) -> Hist:
|
|
24
|
+
"""Create a ``Hist`` with ``Weight`` storage and fill it from ``columns``.
|
|
25
|
+
|
|
26
|
+
One axis is expected per column in ``columns.arrays``. Unweighted data is
|
|
27
|
+
filled without weights, so bin variances equal bin counts.
|
|
28
|
+
"""
|
|
29
|
+
if len(axes) != len(columns.arrays):
|
|
30
|
+
msg = f"got {len(axes)} axes for {len(columns.arrays)} columns"
|
|
31
|
+
raise ValueError(msg)
|
|
32
|
+
histogram = hist.Hist(*axes, storage=hist.storage.Weight())
|
|
33
|
+
if columns.n_entries:
|
|
34
|
+
if columns.weights is None:
|
|
35
|
+
histogram.fill(*columns.arrays)
|
|
36
|
+
else:
|
|
37
|
+
histogram.fill(*columns.arrays, weight=columns.weights)
|
|
38
|
+
return histogram
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def as_weight_storage(histogram: Hist) -> Hist:
|
|
42
|
+
"""Return ``histogram`` with ``Weight`` storage (a copy if it had another storage).
|
|
43
|
+
|
|
44
|
+
Plain count storages (``Double``, ``Int64``, ...) carry no sum of squared
|
|
45
|
+
weights; their variances are taken as ``hist`` reports them, i.e. the
|
|
46
|
+
counts (Poisson) for unweighted fills. If a storage reports no variances at
|
|
47
|
+
all (a ``Double`` histogram filled with weights) the contents are used as
|
|
48
|
+
variances, the best available guess, and the caller is not told otherwise
|
|
49
|
+
because ``hist`` itself makes the same assumption when drawing.
|
|
50
|
+
"""
|
|
51
|
+
if histogram.storage_type is hist.storage.Weight:
|
|
52
|
+
return histogram
|
|
53
|
+
if histogram.ndim and histogram.storage_type not in (
|
|
54
|
+
hist.storage.Double,
|
|
55
|
+
hist.storage.Int64,
|
|
56
|
+
hist.storage.AtomicInt64,
|
|
57
|
+
hist.storage.Unlimited,
|
|
58
|
+
):
|
|
59
|
+
msg = (
|
|
60
|
+
f"histograms with {histogram.storage_type.__name__} storage are not supported; "
|
|
61
|
+
"use Weight (or a plain count) storage"
|
|
62
|
+
)
|
|
63
|
+
raise TypeError(msg)
|
|
64
|
+
values = np.asarray(histogram.values(flow=True), dtype=float)
|
|
65
|
+
reported = histogram.variances(flow=True)
|
|
66
|
+
variances = values if reported is None else np.asarray(reported, dtype=float)
|
|
67
|
+
result = hist.Hist(*histogram.axes, storage=hist.storage.Weight())
|
|
68
|
+
view: Any = result.view(flow=True)
|
|
69
|
+
view.value = values
|
|
70
|
+
view.variance = variances
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Histogram:
|
|
76
|
+
"""A filled histogram together with its provenance and drawing hints.
|
|
77
|
+
|
|
78
|
+
The underlying :class:`Hist` (``Weight`` storage, so bin variances
|
|
79
|
+
are the sums of squared weights) is available as :attr:`hist`; everything
|
|
80
|
+
else is metadata used for legends, ratios and statistics.
|
|
81
|
+
|
|
82
|
+
Attributes
|
|
83
|
+
----------
|
|
84
|
+
hist
|
|
85
|
+
The histogram itself (1D or 2D).
|
|
86
|
+
label
|
|
87
|
+
Legend label.
|
|
88
|
+
sample
|
|
89
|
+
The :class:`~rootfig.model.Sample` this was filled from, if any.
|
|
90
|
+
stats
|
|
91
|
+
Unbinned :class:`~rootfig.histograms.Summary` statistics of the filled
|
|
92
|
+
values, if available.
|
|
93
|
+
is_data
|
|
94
|
+
Whether this represents observed data.
|
|
95
|
+
color, histtype
|
|
96
|
+
Drawing hints, ``None`` for style defaults.
|
|
97
|
+
normalization
|
|
98
|
+
Description of the normalisation applied (``None`` for raw counts).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
hist: Hist
|
|
102
|
+
label: str
|
|
103
|
+
sample: Sample | None = None
|
|
104
|
+
stats: Summary | None = None
|
|
105
|
+
is_data: bool = False
|
|
106
|
+
color: str | None = None
|
|
107
|
+
histtype: HistType | None = None
|
|
108
|
+
normalization: str | None = None
|
|
109
|
+
|
|
110
|
+
# -- convenience accessors -----------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def ndim(self) -> int:
|
|
114
|
+
"""Number of axes."""
|
|
115
|
+
return int(self.hist.ndim)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def axis(self) -> Any:
|
|
119
|
+
"""The first axis."""
|
|
120
|
+
return self.hist.axes[0]
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def edges(self) -> FloatArray:
|
|
124
|
+
"""Bin edges of the first axis."""
|
|
125
|
+
return np.asarray(self.hist.axes[0].edges, dtype=float)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def centers(self) -> FloatArray:
|
|
129
|
+
"""Bin centres of the first axis."""
|
|
130
|
+
return np.asarray(self.hist.axes[0].centers, dtype=float)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def widths(self) -> FloatArray:
|
|
134
|
+
"""Bin widths of the first axis."""
|
|
135
|
+
return np.asarray(self.hist.axes[0].widths, dtype=float)
|
|
136
|
+
|
|
137
|
+
def values(self, *, flow: bool = False) -> FloatArray:
|
|
138
|
+
"""Bin contents (sum of weights)."""
|
|
139
|
+
return np.asarray(self.hist.values(flow=flow), dtype=float)
|
|
140
|
+
|
|
141
|
+
def variances(self, *, flow: bool = False) -> FloatArray:
|
|
142
|
+
"""Bin variances (sum of squared weights)."""
|
|
143
|
+
return np.asarray(self.hist.variances(flow=flow), dtype=float)
|
|
144
|
+
|
|
145
|
+
def errors(self, *, flow: bool = False) -> FloatArray:
|
|
146
|
+
"""Bin uncertainties, ``sqrt(variances)``."""
|
|
147
|
+
return np.asarray(np.sqrt(self.variances(flow=flow)), dtype=float)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def integral(self) -> float:
|
|
151
|
+
"""Sum of the visible bin contents (flow bins excluded)."""
|
|
152
|
+
return float(self.values().sum())
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def underflow(self) -> float:
|
|
156
|
+
"""Content of the underflow bin (first axis, 1D only; ``0`` if the axis has none)."""
|
|
157
|
+
return self._flow_cell("underflow", self.hist.values(flow=True))
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def overflow(self) -> float:
|
|
161
|
+
"""Content of the overflow bin (first axis, 1D only; ``0`` if the axis has none)."""
|
|
162
|
+
return self._flow_cell("overflow", self.hist.values(flow=True))
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def underflow_variance(self) -> float:
|
|
166
|
+
"""Variance of the underflow bin (first axis, 1D only; ``0`` if the axis has none)."""
|
|
167
|
+
return self._flow_cell("underflow", self.hist.variances(flow=True))
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def overflow_variance(self) -> float:
|
|
171
|
+
"""Variance of the overflow bin (first axis, 1D only; ``0`` if the axis has none)."""
|
|
172
|
+
return self._flow_cell("overflow", self.hist.variances(flow=True))
|
|
173
|
+
|
|
174
|
+
def _flow_cell(self, side: str, cells: Any) -> float:
|
|
175
|
+
if self.ndim != 1:
|
|
176
|
+
return float("nan")
|
|
177
|
+
traits = self.axis.traits
|
|
178
|
+
if not getattr(traits, side):
|
|
179
|
+
return 0.0
|
|
180
|
+
return float(cells[0 if side == "underflow" else -1])
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def entries(self) -> int:
|
|
184
|
+
"""Number of filled entries, from the statistics if available."""
|
|
185
|
+
if self.stats is not None:
|
|
186
|
+
return self.stats.entries
|
|
187
|
+
if self.normalization is not None:
|
|
188
|
+
return 0
|
|
189
|
+
return int(np.rint(float(np.sum(self.hist.values(flow=True)))))
|
|
190
|
+
|
|
191
|
+
def with_(self, **changes: Any) -> Histogram:
|
|
192
|
+
"""Return a copy with the given fields replaced."""
|
|
193
|
+
return replace(self, **changes)
|
|
194
|
+
|
|
195
|
+
def scaled(self, factor: float) -> Histogram:
|
|
196
|
+
"""Return a copy multiplied by ``factor`` (variances scale with ``factor**2``)."""
|
|
197
|
+
return replace(self, hist=self.hist * factor)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Cut flows: event counts and weighted yields after successive selections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from rootfig.expressions import parse
|
|
11
|
+
from rootfig.histograms.pipeline import combined_weight, read_arrays
|
|
12
|
+
from rootfig.model.cuts import Cut, CutLike, as_cut
|
|
13
|
+
from rootfig.model.samples import Sample
|
|
14
|
+
from rootfig.selection import NonFinitePolicy, event_mask, event_weights
|
|
15
|
+
|
|
16
|
+
__all__ = ["Cutflow", "CutflowStep", "CutflowTable", "cutflow"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CutflowStep:
|
|
21
|
+
"""Yields after one more cut has been applied.
|
|
22
|
+
|
|
23
|
+
Attributes
|
|
24
|
+
----------
|
|
25
|
+
label
|
|
26
|
+
The cut's label (or expression), ``"All"`` for the first step.
|
|
27
|
+
expression
|
|
28
|
+
The cut expression, empty for the first step.
|
|
29
|
+
events
|
|
30
|
+
Raw number of events passing all cuts so far.
|
|
31
|
+
yield_
|
|
32
|
+
Weighted yield (sum of weights, including the sample scale and luminosity).
|
|
33
|
+
error
|
|
34
|
+
Statistical uncertainty on ``yield_``, ``sqrt(sum w^2)``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
label: str
|
|
38
|
+
expression: str
|
|
39
|
+
events: int
|
|
40
|
+
yield_: float
|
|
41
|
+
error: float
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Cutflow:
|
|
46
|
+
"""The cut flow of one sample: a sequence of :class:`CutflowStep`."""
|
|
47
|
+
|
|
48
|
+
sample: str
|
|
49
|
+
steps: tuple[CutflowStep, ...]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def labels(self) -> list[str]:
|
|
53
|
+
"""Step labels."""
|
|
54
|
+
return [step.label for step in self.steps]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def yields(self) -> np.ndarray:
|
|
58
|
+
"""Weighted yield per step."""
|
|
59
|
+
return np.array([step.yield_ for step in self.steps], dtype=float)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def events(self) -> np.ndarray:
|
|
63
|
+
"""Raw event count per step."""
|
|
64
|
+
return np.array([step.events for step in self.steps], dtype=int)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def efficiencies(self) -> np.ndarray:
|
|
68
|
+
"""Weighted efficiency of each step relative to the previous one (1 for the first)."""
|
|
69
|
+
y = self.yields
|
|
70
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
71
|
+
rel = np.where(y[:-1] > 0, y[1:] / y[:-1], np.nan)
|
|
72
|
+
return np.concatenate([[1.0], rel])
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def absolute_efficiencies(self) -> np.ndarray:
|
|
76
|
+
"""Weighted efficiency of each step relative to the first."""
|
|
77
|
+
y = self.yields
|
|
78
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
79
|
+
return np.where(y[0] > 0, y / y[0], np.nan)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class CutflowTable:
|
|
84
|
+
"""Cut flows of several samples with the same cuts; ``str(table)`` is an aligned text table."""
|
|
85
|
+
|
|
86
|
+
rows: tuple[Cutflow, ...]
|
|
87
|
+
|
|
88
|
+
def get(self, sample: str) -> Cutflow:
|
|
89
|
+
"""Return the cut flow of the sample labelled ``sample``."""
|
|
90
|
+
for flow in self.rows:
|
|
91
|
+
if flow.sample == sample:
|
|
92
|
+
return flow
|
|
93
|
+
msg = f"no cut flow for sample {sample!r}; have {[f.sample for f in self.rows]}"
|
|
94
|
+
raise KeyError(msg)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def samples(self) -> list[str]:
|
|
98
|
+
"""Sample labels in order."""
|
|
99
|
+
return [flow.sample for flow in self.rows]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def labels(self) -> list[str]:
|
|
103
|
+
"""Step labels (identical for all samples)."""
|
|
104
|
+
return self.rows[0].labels if self.rows else []
|
|
105
|
+
|
|
106
|
+
def __str__(self) -> str:
|
|
107
|
+
if not self.rows:
|
|
108
|
+
return ""
|
|
109
|
+
header = ["", *(f"{flow.sample}" for flow in self.rows)]
|
|
110
|
+
lines = [header]
|
|
111
|
+
for index, label in enumerate(self.labels):
|
|
112
|
+
cells = [label]
|
|
113
|
+
for flow in self.rows:
|
|
114
|
+
step = flow.steps[index]
|
|
115
|
+
eff = flow.efficiencies[index]
|
|
116
|
+
if step.events == step.yield_ == int(step.yield_):
|
|
117
|
+
cell = f"{step.events}"
|
|
118
|
+
else:
|
|
119
|
+
cell = f"{_fmt(step.yield_)} ± {_fmt(step.error)} ({step.events})"
|
|
120
|
+
if index > 0:
|
|
121
|
+
cell += f" {100 * eff:5.1f}%"
|
|
122
|
+
cells.append(cell)
|
|
123
|
+
lines.append(cells)
|
|
124
|
+
widths = [max(len(row[i]) for row in lines) for i in range(len(header))]
|
|
125
|
+
return "\n".join(
|
|
126
|
+
" ".join(
|
|
127
|
+
cell.ljust(widths[i]) if i == 0 else cell.rjust(widths[i])
|
|
128
|
+
for i, cell in enumerate(row)
|
|
129
|
+
)
|
|
130
|
+
for row in lines
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fmt(value: float) -> str:
|
|
135
|
+
if value == 0:
|
|
136
|
+
return "0"
|
|
137
|
+
if abs(value) >= 100:
|
|
138
|
+
return f"{value:,.0f}"
|
|
139
|
+
return f"{value:.3g}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cutflow(
|
|
143
|
+
sample: Sample,
|
|
144
|
+
cuts: Sequence[CutLike],
|
|
145
|
+
*,
|
|
146
|
+
weight: str | None = None,
|
|
147
|
+
lumi: float | str | None = None,
|
|
148
|
+
nonfinite: NonFinitePolicy = "drop",
|
|
149
|
+
) -> Cutflow:
|
|
150
|
+
"""Apply ``cuts`` one after another to ``sample`` and count events and yields.
|
|
151
|
+
|
|
152
|
+
The sample's own selection (if any) defines the first step; the plot-level
|
|
153
|
+
``weight`` is multiplied with the sample's weight, scale and luminosity
|
|
154
|
+
factor. Per-object cuts pass an event when any object passes. Events with a
|
|
155
|
+
missing or non-finite weight are excluded from every step (counts and
|
|
156
|
+
yields); non-finite ones are reported following ``nonfinite`` (a
|
|
157
|
+
:class:`~rootfig.errors.RootfigWarning`, or a
|
|
158
|
+
:class:`~rootfig.errors.SelectionError` for ``"error"``).
|
|
159
|
+
"""
|
|
160
|
+
steps: list[Cut] = []
|
|
161
|
+
for item in cuts:
|
|
162
|
+
cut = as_cut(item)
|
|
163
|
+
if cut is not None:
|
|
164
|
+
steps.append(cut)
|
|
165
|
+
weight_expr = combined_weight(sample, weight)
|
|
166
|
+
expressions = [parse(c.expression) for c in steps]
|
|
167
|
+
if sample.selection is not None:
|
|
168
|
+
expressions.append(sample.selection.parsed())
|
|
169
|
+
if weight_expr is not None:
|
|
170
|
+
expressions.append(parse(weight_expr))
|
|
171
|
+
arrays, n_events = read_arrays(sample, expressions)
|
|
172
|
+
weights = event_weights(
|
|
173
|
+
weight_expr, arrays, n_events, nonfinite=nonfinite, context=sample.label
|
|
174
|
+
)
|
|
175
|
+
passing = np.isfinite(weights)
|
|
176
|
+
weights = np.where(passing, weights, 0.0) * sample.scale * sample.lumi_scale(lumi)
|
|
177
|
+
if sample.selection is not None:
|
|
178
|
+
passing &= event_mask(sample.selection.expression, arrays, length=n_events)
|
|
179
|
+
|
|
180
|
+
def step(label: str, expression: str) -> CutflowStep:
|
|
181
|
+
selected = weights[passing]
|
|
182
|
+
return CutflowStep(
|
|
183
|
+
label=label,
|
|
184
|
+
expression=expression,
|
|
185
|
+
events=int(np.count_nonzero(passing)),
|
|
186
|
+
yield_=float(selected.sum()),
|
|
187
|
+
error=float(np.sqrt(np.sum(selected**2))),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
first_label = sample.selection.label or "All" if sample.selection is not None else "All"
|
|
191
|
+
result = [step(first_label, sample.selection.expression if sample.selection else "")]
|
|
192
|
+
for cut in steps:
|
|
193
|
+
passing &= event_mask(cut.expression, arrays, length=n_events)
|
|
194
|
+
result.append(step(cut.label or cut.expression, cut.expression))
|
|
195
|
+
return Cutflow(sample=sample.label, steps=tuple(result))
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Efficiencies (pass / total with binomial intervals) and profiles (a statistic of y per x bin)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal, TypeAlias
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from rootfig._typing import FloatArray, Hist
|
|
13
|
+
from rootfig.errors import BinningError, RootfigWarning
|
|
14
|
+
from rootfig.histograms.ratio import compatible_binning
|
|
15
|
+
|
|
16
|
+
__all__ = ["Efficiency", "Profile", "ProfileStatistic", "efficiency", "profile"]
|
|
17
|
+
|
|
18
|
+
ProfileStatistic: TypeAlias = Literal["mean", "std"]
|
|
19
|
+
"""What a profile shows per bin: the weighted mean of ``y`` or its standard deviation."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Efficiency:
|
|
24
|
+
"""Bin-by-bin efficiency ``passed / total`` with a binomial confidence interval.
|
|
25
|
+
|
|
26
|
+
Attributes
|
|
27
|
+
----------
|
|
28
|
+
values
|
|
29
|
+
The efficiency; ``nan`` where ``total`` is empty.
|
|
30
|
+
lower, upper
|
|
31
|
+
Bounds of the Wilson score interval (``z`` standard deviations; ``z = 1``
|
|
32
|
+
is the usual 68 % band), computed with the effective number of entries
|
|
33
|
+
so weighted samples get sensible intervals. The interval always contains
|
|
34
|
+
the value; it is ``nan`` where the value is outside ``[0, 1]`` (negative
|
|
35
|
+
weights), since a binomial interval is undefined there.
|
|
36
|
+
edges
|
|
37
|
+
Bin edges.
|
|
38
|
+
label
|
|
39
|
+
Legend label.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
values: FloatArray
|
|
43
|
+
lower: FloatArray
|
|
44
|
+
upper: FloatArray
|
|
45
|
+
edges: FloatArray
|
|
46
|
+
label: str = ""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def centers(self) -> FloatArray:
|
|
50
|
+
"""Bin centres."""
|
|
51
|
+
return np.asarray(0.5 * (self.edges[1:] + self.edges[:-1]), dtype=float)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def half_widths(self) -> FloatArray:
|
|
55
|
+
"""Half bin widths."""
|
|
56
|
+
return np.asarray(0.5 * np.diff(self.edges), dtype=float)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def errors(self) -> tuple[FloatArray, FloatArray]:
|
|
60
|
+
"""``(values - lower, upper - values)``, ready for ``yerr``."""
|
|
61
|
+
return (
|
|
62
|
+
np.asarray(self.values - self.lower, dtype=float),
|
|
63
|
+
np.asarray(self.upper - self.values, dtype=float),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def efficiency(passed: Hist, total: Hist, *, z: float = 1.0, label: str = "") -> Efficiency:
|
|
68
|
+
"""Compute ``passed / total`` per bin with a Wilson score interval.
|
|
69
|
+
|
|
70
|
+
Both histograms must share their binning; ``passed`` should be a subset of
|
|
71
|
+
``total``. With weights the effective counts ``(sum w)^2 / sum w^2`` of the
|
|
72
|
+
total replace the raw counts in the interval. The interval is clipped to
|
|
73
|
+
``[0, 1]`` and always contains the efficiency (at 0 % and 100 % the
|
|
74
|
+
respective bound coincides with the value). Bins whose efficiency falls
|
|
75
|
+
outside ``[0, 1]``, which can only happen with negative weights, get ``nan``
|
|
76
|
+
bounds and a :class:`~rootfig.errors.RootfigWarning`: no binomial interval
|
|
77
|
+
describes them.
|
|
78
|
+
|
|
79
|
+
Raises
|
|
80
|
+
------
|
|
81
|
+
BinningError
|
|
82
|
+
If the binnings differ or ``z`` is not a positive finite number.
|
|
83
|
+
"""
|
|
84
|
+
if not compatible_binning(passed, total):
|
|
85
|
+
msg = "efficiency requires two one-dimensional histograms with identical bin edges"
|
|
86
|
+
raise BinningError(msg)
|
|
87
|
+
if not (math.isfinite(z) and z > 0):
|
|
88
|
+
msg = f"z must be a positive finite number of standard deviations, got {z!r}"
|
|
89
|
+
raise BinningError(msg)
|
|
90
|
+
k = np.asarray(passed.values(), dtype=float)
|
|
91
|
+
n = np.asarray(total.values(), dtype=float)
|
|
92
|
+
vn = np.asarray(total.variances(), dtype=float)
|
|
93
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
94
|
+
ok = n > 0
|
|
95
|
+
p = np.where(ok, k / n, np.nan)
|
|
96
|
+
n_eff = np.where(vn > 0, n**2 / vn, n)
|
|
97
|
+
z2 = z * z
|
|
98
|
+
denominator = 1.0 + z2 / n_eff
|
|
99
|
+
centre = (p + z2 / (2.0 * n_eff)) / denominator
|
|
100
|
+
half = (z / denominator) * np.sqrt(p * (1.0 - p) / n_eff + z2 / (4.0 * n_eff**2))
|
|
101
|
+
valid = ok & (p >= 0.0) & (p <= 1.0)
|
|
102
|
+
# the Wilson interval contains p by construction; guard against round-off at 0 and 1
|
|
103
|
+
lower = np.where(valid, np.minimum(np.clip(centre - half, 0.0, 1.0), p), np.nan)
|
|
104
|
+
upper = np.where(valid, np.maximum(np.clip(centre + half, 0.0, 1.0), p), np.nan)
|
|
105
|
+
undefined = int(np.count_nonzero(ok & ~valid))
|
|
106
|
+
if undefined:
|
|
107
|
+
warnings.warn(
|
|
108
|
+
f"{label + ': ' if label else ''}{undefined} bin(s) have an efficiency outside "
|
|
109
|
+
"[0, 1] (negative weights?); no confidence interval is drawn for them",
|
|
110
|
+
RootfigWarning,
|
|
111
|
+
stacklevel=2,
|
|
112
|
+
)
|
|
113
|
+
return Efficiency(
|
|
114
|
+
values=np.asarray(p, dtype=float),
|
|
115
|
+
lower=np.asarray(lower, dtype=float),
|
|
116
|
+
upper=np.asarray(upper, dtype=float),
|
|
117
|
+
edges=np.asarray(total.axes[0].edges, dtype=float),
|
|
118
|
+
label=label,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Profile:
|
|
124
|
+
"""A statistic of ``y`` in bins of ``x``.
|
|
125
|
+
|
|
126
|
+
Attributes
|
|
127
|
+
----------
|
|
128
|
+
values
|
|
129
|
+
The weighted mean (``statistic="mean"``) or standard deviation
|
|
130
|
+
(``"std"``) of ``y`` per bin; ``nan`` for empty bins, and ``nan`` for
|
|
131
|
+
the standard deviation where negative weights make the weighted
|
|
132
|
+
variance negative.
|
|
133
|
+
errors
|
|
134
|
+
Standard error: ``std / sqrt(n_eff)`` for the mean, ``std / sqrt(2 n_eff)``
|
|
135
|
+
for the standard deviation, with the effective entries ``n_eff``.
|
|
136
|
+
counts
|
|
137
|
+
Sum of weights per bin.
|
|
138
|
+
edges
|
|
139
|
+
Bin edges.
|
|
140
|
+
statistic, label
|
|
141
|
+
What is shown and the legend label.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
values: FloatArray
|
|
145
|
+
errors: FloatArray
|
|
146
|
+
counts: FloatArray
|
|
147
|
+
edges: FloatArray
|
|
148
|
+
statistic: ProfileStatistic = "mean"
|
|
149
|
+
label: str = ""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def centers(self) -> FloatArray:
|
|
153
|
+
"""Bin centres."""
|
|
154
|
+
return np.asarray(0.5 * (self.edges[1:] + self.edges[:-1]), dtype=float)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def half_widths(self) -> FloatArray:
|
|
158
|
+
"""Half bin widths."""
|
|
159
|
+
return np.asarray(0.5 * np.diff(self.edges), dtype=float)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def profile(
|
|
163
|
+
x: FloatArray,
|
|
164
|
+
y: FloatArray,
|
|
165
|
+
edges: FloatArray,
|
|
166
|
+
*,
|
|
167
|
+
weights: FloatArray | None = None,
|
|
168
|
+
statistic: ProfileStatistic = "mean",
|
|
169
|
+
label: str = "",
|
|
170
|
+
) -> Profile:
|
|
171
|
+
"""Bin ``x`` with ``edges`` and compute the weighted mean or standard deviation of ``y``.
|
|
172
|
+
|
|
173
|
+
The variance is computed from the deviations from the bin mean (two passes),
|
|
174
|
+
so a narrow spread at a large offset, e.g. ``std([1e9, 1e9 + 1]) = 0.5``, is
|
|
175
|
+
not lost to cancellation.
|
|
176
|
+
"""
|
|
177
|
+
x = np.asarray(x, dtype=float)
|
|
178
|
+
y = np.asarray(y, dtype=float)
|
|
179
|
+
edges = np.asarray(edges, dtype=float)
|
|
180
|
+
w = np.ones_like(x) if weights is None else np.asarray(weights, dtype=float)
|
|
181
|
+
if not (x.shape == y.shape == w.shape):
|
|
182
|
+
msg = "x, y and weights must have the same length"
|
|
183
|
+
raise BinningError(msg)
|
|
184
|
+
if statistic not in ("mean", "std"):
|
|
185
|
+
msg = f"statistic must be 'mean' or 'std', got {statistic!r}"
|
|
186
|
+
raise BinningError(msg)
|
|
187
|
+
n_bins = len(edges) - 1
|
|
188
|
+
index = np.digitize(x, edges) - 1
|
|
189
|
+
inside = (index >= 0) & (index < n_bins)
|
|
190
|
+
index, y, w = index[inside], y[inside], w[inside]
|
|
191
|
+
sw = np.bincount(index, weights=w, minlength=n_bins)
|
|
192
|
+
sw2 = np.bincount(index, weights=w * w, minlength=n_bins)
|
|
193
|
+
swy = np.bincount(index, weights=w * y, minlength=n_bins)
|
|
194
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
195
|
+
ok = sw > 0
|
|
196
|
+
mean = np.where(ok, swy / sw, np.nan)
|
|
197
|
+
residual = y - np.where(ok, mean, 0.0)[index]
|
|
198
|
+
swr2 = np.bincount(index, weights=w * residual * residual, minlength=n_bins)
|
|
199
|
+
variance = np.where(ok, swr2 / sw, np.nan)
|
|
200
|
+
variance = np.where(variance < 0, np.nan, variance) # negative weights: undefined
|
|
201
|
+
std = np.sqrt(variance)
|
|
202
|
+
n_eff = np.where(sw2 > 0, sw**2 / sw2, 0.0)
|
|
203
|
+
if statistic == "mean":
|
|
204
|
+
values, errors = mean, np.where(n_eff > 0, std / np.sqrt(n_eff), np.nan)
|
|
205
|
+
else:
|
|
206
|
+
values, errors = std, np.where(n_eff > 0, std / np.sqrt(2.0 * n_eff), np.nan)
|
|
207
|
+
return Profile(
|
|
208
|
+
values=np.asarray(values, dtype=float),
|
|
209
|
+
errors=np.asarray(errors, dtype=float),
|
|
210
|
+
counts=np.asarray(sw, dtype=float),
|
|
211
|
+
edges=edges,
|
|
212
|
+
statistic=statistic,
|
|
213
|
+
label=label,
|
|
214
|
+
)
|