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.
@@ -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
+ )