aptapy 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- {aptapy-0.1.1 → aptapy-0.2.0}/PKG-INFO +1 -1
- aptapy-0.2.0/docs/hist.rst +45 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/index.rst +1 -0
- aptapy-0.2.0/docs/release_notes.rst +20 -0
- aptapy-0.2.0/src/aptapy/_version.py +1 -0
- aptapy-0.2.0/src/aptapy/hist.py +226 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/modeling.py +22 -22
- aptapy-0.2.0/src/aptapy/plotting.py +523 -0
- aptapy-0.2.0/tests/test_hist.py +123 -0
- aptapy-0.1.1/docs/release_notes.rst +0 -12
- aptapy-0.1.1/src/aptapy/_version.py +0 -1
- aptapy-0.1.1/src/aptapy/plotting.py +0 -486
- {aptapy-0.1.1 → aptapy-0.2.0}/.github/workflows/ci.yml +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/.github/workflows/docs.yml +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/.github/workflows/pypi.yml +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/.gitignore +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/CODE_OF_CONDUCT.md +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/CONTRIBUTING.md +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/LICENSE +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/README.md +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/Makefile +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/_static/favicon.ico +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/_static/logo.png +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/_static/logo_small.png +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/conf.py +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/make.bat +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/docs/modeling.rst +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/noxfile.py +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/pyproject.toml +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/__init__.py +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/py.typed +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/typing_.py +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/tests/test_modeling.py +0 -0
- {aptapy-0.1.1 → aptapy-0.2.0}/tools/release.py +0 -0
@@ -0,0 +1,45 @@
|
|
1
|
+
.. _hist:
|
2
|
+
|
3
|
+
:mod:`~aptapy.hist` --- Histograms
|
4
|
+
==================================
|
5
|
+
|
6
|
+
The module provides an abstract base class for n-dimensional histograms along
|
7
|
+
with concrete implementations for 1D and 2D histograms:
|
8
|
+
|
9
|
+
* :class:`~aptapy.hist.AbstractHistogram`: Abstract base class for histograms;
|
10
|
+
* :class:`~aptapy.hist.Histogram1d`: 1D histogram implementation;
|
11
|
+
* :class:`~aptapy.hist.Histogram2d`: 2D histogram implementation.
|
12
|
+
|
13
|
+
Histograms are constructed with the bin edges (and, optionally, labels to be
|
14
|
+
used at the plotting stage) and are filled using the
|
15
|
+
:meth:`~aptapy.hist.AbstractHistogram.fill` method. The basic semantics is as
|
16
|
+
follows:
|
17
|
+
|
18
|
+
.. code-block:: python
|
19
|
+
|
20
|
+
import numpy as np
|
21
|
+
from aptapy.hist import Histogram1d, Histogram2d
|
22
|
+
|
23
|
+
rng = np.random.default_rng()
|
24
|
+
edges = np.linspace(-5., 5., 100)
|
25
|
+
|
26
|
+
hist = Histogram1d(edges, "x")
|
27
|
+
hist.fill(rng.normal(size=1000))
|
28
|
+
hist.plot()
|
29
|
+
|
30
|
+
hist = Histogram2d(edges, edges, 'x', 'y')
|
31
|
+
hist.fill(rng.normal(size=1000), rng.normal(size=1000))
|
32
|
+
hist.plot()
|
33
|
+
|
34
|
+
Histograms support weighted filling and basic arithmetic operations (addition
|
35
|
+
and subtraction) between histograms with identical binning.
|
36
|
+
|
37
|
+
.. warning::
|
38
|
+
|
39
|
+
Multiplication by a scalar is not yet supported.
|
40
|
+
|
41
|
+
|
42
|
+
Module documentation
|
43
|
+
--------------------
|
44
|
+
|
45
|
+
.. automodule:: aptapy.hist
|
@@ -0,0 +1,20 @@
|
|
1
|
+
.. _release_notes:
|
2
|
+
|
3
|
+
Release notes
|
4
|
+
=============
|
5
|
+
|
6
|
+
|
7
|
+
Version 0.2.0 (2025-10-06)
|
8
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
9
|
+
|
10
|
+
* New histogram facilities added.
|
11
|
+
|
12
|
+
* Pull requests merged:
|
13
|
+
|
14
|
+
- https://github.com/lucabaldini/aptapy/pull/2
|
15
|
+
|
16
|
+
|
17
|
+
Version 0.1.1 (2025-10-03)
|
18
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
19
|
+
|
20
|
+
Initial release on PyPI.
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.2.0"
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# Copyright (C) 2025 Luca Baldini (luca.baldini@pi.infn.it)
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
"""Histogram facilities.
|
17
|
+
"""
|
18
|
+
|
19
|
+
from abc import ABC, abstractmethod
|
20
|
+
from typing import List, Sequence
|
21
|
+
|
22
|
+
import numpy as np
|
23
|
+
|
24
|
+
from .plotting import matplotlib, plt, setup_axes
|
25
|
+
from .typing_ import ArrayLike
|
26
|
+
|
27
|
+
|
28
|
+
class AbstractHistogram(ABC):
|
29
|
+
|
30
|
+
"""Abstract base class for an n-dimensional histogram.
|
31
|
+
|
32
|
+
Arguments
|
33
|
+
---------
|
34
|
+
edges : n-dimensional sequence of arrays
|
35
|
+
the bin edges on the different axes.
|
36
|
+
|
37
|
+
labels : sequence of strings
|
38
|
+
the text labels for the different axes.
|
39
|
+
"""
|
40
|
+
|
41
|
+
DEFAULT_PLOT_OPTIONS = {}
|
42
|
+
|
43
|
+
def __init__(self, edges: Sequence[np.ndarray], labels: List[str]) -> None:
|
44
|
+
"""Constructor.
|
45
|
+
"""
|
46
|
+
# Edges are fixed once and forever, so we create a copy. Also, no matter
|
47
|
+
# which kind of sequence we are passing, we turn the thing into a tuple.
|
48
|
+
self._edges = tuple(np.asarray(item, dtype=float).copy() for item in edges)
|
49
|
+
self._num_axes = len(self._edges)
|
50
|
+
|
51
|
+
# And a few basic checks on the input arguments.
|
52
|
+
for item in self._edges:
|
53
|
+
if item.ndim != 1:
|
54
|
+
raise ValueError(f"Bin edges {item} are not a 1-dimensional array.")
|
55
|
+
if item.size < 2:
|
56
|
+
raise ValueError(f"Bin edges {item} have less than 2 entries.")
|
57
|
+
if np.any(np.diff(item) <= 0):
|
58
|
+
raise ValueError(f"Bin edges {item} not strictly increasing.")
|
59
|
+
if labels is not None and len(labels) > self._num_axes + 1:
|
60
|
+
raise ValueError(f"Too many labels {labels} for {self._num_axes} axes.")
|
61
|
+
|
62
|
+
# Go ahead and create all the necessary data structures.
|
63
|
+
self._shape = tuple(item.size - 1 for item in self._edges)
|
64
|
+
self._sumw = np.zeros(self._shape, dtype=float)
|
65
|
+
self._sumw2 = np.zeros(self._shape, dtype=float)
|
66
|
+
self._labels = labels
|
67
|
+
|
68
|
+
@property
|
69
|
+
def content(self) -> np.ndarray:
|
70
|
+
"""Return the bin contents.
|
71
|
+
"""
|
72
|
+
return self._sumw
|
73
|
+
|
74
|
+
@property
|
75
|
+
def errors(self) -> np.ndarray:
|
76
|
+
"""Return the bin errors.
|
77
|
+
"""
|
78
|
+
return np.sqrt(self._sumw2)
|
79
|
+
|
80
|
+
def bin_edges(self, axis: int = 0) -> np.ndarray:
|
81
|
+
"""Return a view on the binning for specific axis.
|
82
|
+
"""
|
83
|
+
return self._edges[axis].view()
|
84
|
+
|
85
|
+
def bin_centers(self, axis: int = 0) -> np.ndarray:
|
86
|
+
"""Return the bin centers for a specific axis.
|
87
|
+
"""
|
88
|
+
return 0.5 * (self._edges[axis][1:] + self._edges[axis][:-1])
|
89
|
+
|
90
|
+
def bin_widths(self, axis: int = 0) -> np.ndarray:
|
91
|
+
"""Return the bin widths for a specific axis.
|
92
|
+
"""
|
93
|
+
return np.diff(self._edges[axis])
|
94
|
+
|
95
|
+
def fill(self, *values: ArrayLike, weights: ArrayLike = None) -> "AbstractHistogram":
|
96
|
+
"""Fill the histogram from unbinned data.
|
97
|
+
|
98
|
+
Note this method is returning the histogram instance, so that the function
|
99
|
+
call can be chained.
|
100
|
+
"""
|
101
|
+
values = np.vstack(values).T
|
102
|
+
sumw, _ = np.histogramdd(values, bins=self._edges, weights=weights)
|
103
|
+
if weights is None:
|
104
|
+
sumw2 = sumw
|
105
|
+
else:
|
106
|
+
sumw2, _ = np.histogramdd(values, bins=self._edges, weights=weights**2.)
|
107
|
+
self._sumw += sumw
|
108
|
+
self._sumw2 += sumw2
|
109
|
+
return self
|
110
|
+
|
111
|
+
def copy(self) -> "AbstractHistogram":
|
112
|
+
"""Create a full copy of a histogram.
|
113
|
+
"""
|
114
|
+
# pylint: disable=protected-access
|
115
|
+
# Note we really need the * in the constructor, here, as the abstract
|
116
|
+
# base class is never instantiated, and the arguments are unpacked in the
|
117
|
+
# constructors of all the derived classes.
|
118
|
+
histogram = self.__class__(*self._edges, *self._labels)
|
119
|
+
histogram._sumw = self._sumw.copy()
|
120
|
+
histogram._sumw2 = self._sumw2.copy()
|
121
|
+
return histogram
|
122
|
+
|
123
|
+
def _check_compat(self, other: "AbstractHistogram") -> None:
|
124
|
+
"""Check whether two histogram objects are compatible with each other,
|
125
|
+
meaning, e.g., that they can be summed or subtracted.
|
126
|
+
"""
|
127
|
+
# pylint: disable=protected-access
|
128
|
+
if not isinstance(other, AbstractHistogram):
|
129
|
+
raise TypeError(f"{other} is not a histogram.")
|
130
|
+
if self._num_axes != other._num_axes or self._shape != other._shape:
|
131
|
+
raise ValueError("Histogram dimensionality/shape mismatch.")
|
132
|
+
for edges in zip(self._edges, other._edges):
|
133
|
+
if not np.allclose(*edges):
|
134
|
+
raise ValueError("Histogram bin edges differ.")
|
135
|
+
|
136
|
+
def __iadd__(self, other: "AbstractHistogram") -> "AbstractHistogram":
|
137
|
+
"""Histogram addition (in place).
|
138
|
+
"""
|
139
|
+
self._check_compat(other)
|
140
|
+
self._sumw += other._sumw
|
141
|
+
self._sumw2 += other._sumw2
|
142
|
+
return self
|
143
|
+
|
144
|
+
def __add__(self, other: "AbstractHistogram") -> "AbstractHistogram":
|
145
|
+
"""Histogram addition.
|
146
|
+
"""
|
147
|
+
histogram = self.copy()
|
148
|
+
histogram += other
|
149
|
+
return histogram
|
150
|
+
|
151
|
+
def __isub__(self, other: "AbstractHistogram") -> "AbstractHistogram":
|
152
|
+
"""Histogram subtraction (in place).
|
153
|
+
"""
|
154
|
+
self._check_compat(other)
|
155
|
+
self._sumw -= other._sumw
|
156
|
+
self._sumw2 += other._sumw2
|
157
|
+
return self
|
158
|
+
|
159
|
+
def __sub__(self, other: "AbstractHistogram") -> "AbstractHistogram":
|
160
|
+
"""Histogram subtraction.
|
161
|
+
"""
|
162
|
+
histogram = self.copy()
|
163
|
+
histogram -= other
|
164
|
+
return histogram
|
165
|
+
|
166
|
+
@abstractmethod
|
167
|
+
def _do_plot(self, axes, **kwargs) -> None:
|
168
|
+
pass
|
169
|
+
|
170
|
+
def plot(self, axes=None, **kwargs) -> None:
|
171
|
+
"""Plot the histogram.
|
172
|
+
"""
|
173
|
+
if axes is None:
|
174
|
+
axes = plt.gca()
|
175
|
+
for key, value in self.DEFAULT_PLOT_OPTIONS.items():
|
176
|
+
kwargs.setdefault(key, value)
|
177
|
+
self._do_plot(axes, **kwargs)
|
178
|
+
|
179
|
+
def __repr__(self) -> str:
|
180
|
+
"""String representation of the histogram.
|
181
|
+
"""
|
182
|
+
return f"{self.__class__.__name__}({self._num_axes} axes, shape={self._shape})"
|
183
|
+
|
184
|
+
|
185
|
+
class Histogram1d(AbstractHistogram):
|
186
|
+
|
187
|
+
"""One-dimensional histogram.
|
188
|
+
"""
|
189
|
+
|
190
|
+
DEFAULT_PLOT_OPTIONS = dict(linewidth=1.25, alpha=0.4, histtype="stepfilled")
|
191
|
+
|
192
|
+
def __init__(self, xedges: np.ndarray, xlabel: str = "", ylabel: str = "Entries/bin") -> None:
|
193
|
+
"""Constructor.
|
194
|
+
"""
|
195
|
+
super().__init__((xedges, ), [xlabel, ylabel])
|
196
|
+
|
197
|
+
def _do_plot(self, axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
|
198
|
+
"""Overloaded make_plot() method.
|
199
|
+
"""
|
200
|
+
axes.hist(self.bin_centers(0), self._edges[0], weights=self.content, **kwargs)
|
201
|
+
setup_axes(axes, xlabel=self._labels[0], ylabel=self._labels[1])
|
202
|
+
|
203
|
+
|
204
|
+
class Histogram2d(AbstractHistogram):
|
205
|
+
|
206
|
+
"""Two-dimensional histogram.
|
207
|
+
"""
|
208
|
+
|
209
|
+
DEFAULT_PLOT_OPTIONS = dict(cmap=plt.get_cmap('hot'))
|
210
|
+
|
211
|
+
def __init__(self, xedges, yedges, xlabel='', ylabel='', zlabel='Entries/bin') -> None:
|
212
|
+
"""Constructor.
|
213
|
+
"""
|
214
|
+
super().__init__((xedges, yedges), [xlabel, ylabel, zlabel])
|
215
|
+
|
216
|
+
def _do_plot(self, axes: matplotlib.axes._axes.Axes, logz: bool = False, **kwargs) -> None:
|
217
|
+
"""Overloaded make_plot() method.
|
218
|
+
"""
|
219
|
+
# pylint: disable=arguments-differ
|
220
|
+
if logz:
|
221
|
+
vmin = kwargs.pop('vmin', None)
|
222
|
+
vmax = kwargs.pop('vmax', None)
|
223
|
+
kwargs.setdefault('norm', matplotlib.colors.LogNorm(vmin, vmax))
|
224
|
+
mappable = axes.pcolormesh(*self._edges, self.content.T, **kwargs)
|
225
|
+
plt.colorbar(mappable, ax=axes, label=self._labels[2])
|
226
|
+
setup_axes(axes, xlabel=self._labels[0], ylabel=self._labels[1])
|
@@ -37,11 +37,11 @@ class Format(str, enum.Enum):
|
|
37
37
|
"""Small enum class to control string formatting.
|
38
38
|
|
39
39
|
This is leveraging the custom formatting of the uncertainties package, where
|
40
|
-
a trailing
|
40
|
+
a trailing `P` means "pretty print" and a trailing `L` means "LaTeX".
|
41
41
|
"""
|
42
42
|
|
43
|
-
PRETTY =
|
44
|
-
LATEX =
|
43
|
+
PRETTY = "P"
|
44
|
+
LATEX = "L"
|
45
45
|
|
46
46
|
|
47
47
|
@dataclass
|
@@ -80,7 +80,7 @@ class FitParameter:
|
|
80
80
|
"""
|
81
81
|
return not np.isinf(self.minimum) or not np.isinf(self.maximum)
|
82
82
|
|
83
|
-
def copy(self, name: str) ->
|
83
|
+
def copy(self, name: str) -> "FitParameter":
|
84
84
|
"""Create a copy of the parameter object with a new name.
|
85
85
|
|
86
86
|
This is necessary because we define the fit parameters of the actual model as
|
@@ -139,24 +139,24 @@ class FitParameter:
|
|
139
139
|
"""String formatting.
|
140
140
|
"""
|
141
141
|
# Keep in mind Python passes an empty string explicitly when you call
|
142
|
-
# f
|
142
|
+
# f"{parameter}", so we can't really assign a default value to spec.
|
143
143
|
if self.error is not None:
|
144
144
|
param = format(self.ufloat(), spec)
|
145
145
|
if spec.endswith(Format.LATEX):
|
146
|
-
param = f
|
146
|
+
param = f"${param}$"
|
147
147
|
else:
|
148
148
|
spec = spec.rstrip(Format.PRETTY).rstrip(Format.LATEX)
|
149
149
|
param = format(self.value, spec)
|
150
|
-
text = f
|
150
|
+
text = f"{self._name.title()}: {param}"
|
151
151
|
info = []
|
152
152
|
if self._frozen:
|
153
|
-
info.append(
|
153
|
+
info.append("frozen")
|
154
154
|
if not np.isinf(self.minimum):
|
155
|
-
info.append(f
|
155
|
+
info.append(f"min={self.minimum}")
|
156
156
|
if not np.isinf(self.maximum):
|
157
|
-
info.append(f
|
157
|
+
info.append(f"max={self.maximum}")
|
158
158
|
if info:
|
159
|
-
text = f
|
159
|
+
text = f"{text} ({', '.join(info)})"
|
160
160
|
return text
|
161
161
|
|
162
162
|
def __str__(self) -> str:
|
@@ -191,12 +191,12 @@ class FitStatus:
|
|
191
191
|
"""String formatting.
|
192
192
|
"""
|
193
193
|
if self.chisquare is None:
|
194
|
-
return
|
194
|
+
return "N/A"
|
195
195
|
if spec.endswith(Format.LATEX):
|
196
|
-
return f
|
196
|
+
return f"$\\chi^2$ = {self.chisquare:.2f} / {self.dof} dof"
|
197
197
|
if spec.endswith(Format.PRETTY):
|
198
|
-
return f
|
199
|
-
return f
|
198
|
+
return f"χ² = {self.chisquare:.2f} / {self.dof} dof"
|
199
|
+
return f"chisquare = {self.chisquare:.2f} / {self.dof} dof"
|
200
200
|
|
201
201
|
def __str__(self) -> str:
|
202
202
|
"""String formatting.
|
@@ -337,7 +337,7 @@ class AbstractFitModel(ABC):
|
|
337
337
|
# print out an error message.
|
338
338
|
unknown_parameter_names = set(constraints) - set(parameter_names)
|
339
339
|
if unknown_parameter_names:
|
340
|
-
raise ValueError(f
|
340
|
+
raise ValueError(f"Cannot freeze unknown parameters {unknown_parameter_names}")
|
341
341
|
|
342
342
|
# Now we need to build the signature for the new function, starting from a
|
343
343
|
# clean copy of the parameter for the independent variable...
|
@@ -353,8 +353,8 @@ class AbstractFitModel(ABC):
|
|
353
353
|
@functools.wraps(model_function)
|
354
354
|
def wrapper(x, *args):
|
355
355
|
if len(args) != num_free_parameters:
|
356
|
-
raise TypeError(f
|
357
|
-
f
|
356
|
+
raise TypeError(f"Frozen wrapper got {len(args)} parameters instead of " \
|
357
|
+
f"{num_free_parameters} ({free_parameter_names})")
|
358
358
|
parameter_dict = {**dict(zip(free_parameter_names, args)), **constraints}
|
359
359
|
return model_function(x, *[parameter_dict[name] for name in parameter_names])
|
360
360
|
|
@@ -379,7 +379,7 @@ class AbstractFitModel(ABC):
|
|
379
379
|
# (And, since we are at it, make sure we have enough degrees of freedom.)
|
380
380
|
self.status.dof = int(mask.sum() - len(self))
|
381
381
|
if self.status.dof < 0:
|
382
|
-
raise RuntimeError(f
|
382
|
+
raise RuntimeError(f"{self.name()} has no degrees of freedom")
|
383
383
|
xdata = xdata[mask]
|
384
384
|
ydata = ydata[mask]
|
385
385
|
if not isinstance(sigma, Number):
|
@@ -444,10 +444,10 @@ class AbstractFitModel(ABC):
|
|
444
444
|
def __format__(self, spec: str) -> str:
|
445
445
|
"""String formatting.
|
446
446
|
"""
|
447
|
-
text = f
|
447
|
+
text = f"{self.__class__.__name__} ({format(self.status, spec)})\n"
|
448
448
|
for parameter in self._parameters:
|
449
|
-
text = f
|
450
|
-
return text.strip(
|
449
|
+
text = f"{text}{format(parameter, spec)}\n"
|
450
|
+
return text.strip("\n")
|
451
451
|
|
452
452
|
def __str__(self):
|
453
453
|
"""String formatting.
|