aptapy 0.3.1__tar.gz → 0.4.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.3.1 → aptapy-0.4.0}/PKG-INFO +1 -2
- {aptapy-0.3.1 → aptapy-0.4.0}/README.md +0 -1
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/constrained_fit.py +3 -3
- aptapy-0.4.0/docs/examples/simple_hist2d.py +22 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/release_notes.rst +30 -0
- aptapy-0.4.0/src/aptapy/_version.py +1 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/hist.py +56 -7
- {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/modeling.py +265 -21
- {aptapy-0.3.1 → aptapy-0.4.0}/tests/test_hist.py +32 -5
- {aptapy-0.3.1 → aptapy-0.4.0}/tests/test_modeling.py +97 -2
- aptapy-0.3.1/src/aptapy/_version.py +0 -1
- {aptapy-0.3.1 → aptapy-0.4.0}/.github/workflows/ci.yml +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/.github/workflows/docs.yml +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/.github/workflows/pypi.yml +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/.gitignore +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/CONTRIBUTING.md +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/LICENSE +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/Makefile +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/_static/favicon.ico +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/_static/logo.png +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/_static/logo_small.png +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/conf.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/GALLERY_HEADER.rst +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/composite_fit.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/simple_fit.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/simple_hist1d.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/weighted_hist1d.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/hist.rst +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/index.rst +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/make.bat +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/modeling.rst +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/docs/strip.rst +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/noxfile.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/pyproject.toml +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/__init__.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/plotting.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/py.typed +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/strip.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/typing_.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/tests/test_strip.py +0 -0
- {aptapy-0.3.1 → aptapy-0.4.0}/tools/release.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: aptapy
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.4.0
|
4
4
|
Summary: Statistical tools for online monitoring and analysis
|
5
5
|
Project-URL: Homepage, https://github.com/lucabaldini/aptapy
|
6
6
|
Project-URL: Issues, https://github.com/lucabaldini/aptapy/issues
|
@@ -713,7 +713,6 @@ Description-Content-Type: text/markdown
|
|
713
713
|

|
714
714
|
[](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml)
|
715
715
|
[](https://github.com/lucabaldini/aptapy/actions/workflows/docs.yml)
|
716
|
-
[](https://lucabaldini.github.io/aptapy/)
|
717
716
|

|
718
717
|
|
719
718
|
[](https://techforpalestine.org/learn-more)
|
@@ -5,7 +5,6 @@
|
|
5
5
|

|
6
6
|
[](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml)
|
7
7
|
[](https://github.com/lucabaldini/aptapy/actions/workflows/docs.yml)
|
8
|
-
[](https://lucabaldini.github.io/aptapy/)
|
9
8
|

|
10
9
|
|
11
10
|
[](https://techforpalestine.org/learn-more)
|
@@ -19,9 +19,9 @@ hist.fill(np.random.default_rng().normal(size=100000))
|
|
19
19
|
hist.plot()
|
20
20
|
|
21
21
|
model = Gaussian()
|
22
|
-
# Fix the prefactor
|
23
|
-
#
|
24
|
-
model.prefactor.freeze(hist.area())
|
22
|
+
# Fix the prefactor. This is a generally useful technique, as you should
|
23
|
+
# never fit the normalization of a histogram.
|
24
|
+
model.prefactor.freeze(hist.area() / model.integral(-5., 5.))
|
25
25
|
model.fit_histogram(hist)
|
26
26
|
print(model)
|
27
27
|
model.plot()
|
@@ -0,0 +1,22 @@
|
|
1
|
+
"""
|
2
|
+
Simple 2-D histogram
|
3
|
+
====================
|
4
|
+
|
5
|
+
Simple two-dimensional histogram filled with bivariate gaussians random numbers.
|
6
|
+
"""
|
7
|
+
|
8
|
+
# %%
|
9
|
+
|
10
|
+
import numpy as np
|
11
|
+
|
12
|
+
from aptapy.hist import Histogram2d
|
13
|
+
from aptapy.plotting import plt
|
14
|
+
|
15
|
+
edges = np.linspace(-5., 5., 100)
|
16
|
+
hist = Histogram2d(edges, edges, label="Random data", xlabel="x", ylabel="y")
|
17
|
+
x= np.random.default_rng().normal(loc=1., size=100000)
|
18
|
+
y= np.random.default_rng().normal(loc=-1., size=100000)
|
19
|
+
hist.fill(x, y)
|
20
|
+
hist.plot()
|
21
|
+
|
22
|
+
plt.gca().set_aspect("equal")
|
@@ -4,9 +4,39 @@ Release notes
|
|
4
4
|
=============
|
5
5
|
|
6
6
|
|
7
|
+
Version 0.4.0 (2025-10-11)
|
8
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
9
|
+
|
10
|
+
|
11
|
+
* Added 2-dimensional histogram example.
|
12
|
+
* Adds several new model classes (Quadratic, PowerLaw, Exponential, Erf, ErfInverse).
|
13
|
+
* Implements analytical integration methods for models where possible, with a fallback
|
14
|
+
to numerical integration in the base class.
|
15
|
+
* Updates the FitStatus class with a completion check method.
|
16
|
+
|
17
|
+
* Pull requests merged:
|
18
|
+
|
19
|
+
- https://github.com/lucabaldini/aptapy/pull/7
|
20
|
+
|
21
|
+
|
22
|
+
Version 0.3.2 (2025-10-09)
|
23
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
24
|
+
|
25
|
+
* Adding binned_statistics method in AbstractHistogram base class to calculate
|
26
|
+
statistics from histogram bins
|
27
|
+
* Adds extensive test coverage in both 1D and 2D histogram test functions with
|
28
|
+
statistical validation
|
29
|
+
|
30
|
+
* Pull requests merged:
|
31
|
+
|
32
|
+
- https://github.com/lucabaldini/aptapy/pull/6
|
33
|
+
|
34
|
+
|
7
35
|
Version 0.3.1 (2025-10-09)
|
8
36
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
9
37
|
|
38
|
+
* Minor changes.
|
39
|
+
|
10
40
|
|
11
41
|
Version 0.3.0 (2025-10-08)
|
12
42
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.4.0"
|
@@ -17,13 +17,17 @@
|
|
17
17
|
"""
|
18
18
|
|
19
19
|
from abc import ABC, abstractmethod
|
20
|
-
from typing import List, Sequence
|
20
|
+
from typing import List, Sequence, Tuple
|
21
21
|
|
22
22
|
import numpy as np
|
23
23
|
|
24
24
|
from .plotting import matplotlib, plt, setup_axes
|
25
25
|
from .typing_ import ArrayLike
|
26
26
|
|
27
|
+
__all__ = [
|
28
|
+
"Histogram1d",
|
29
|
+
"Histogram2d",
|
30
|
+
]
|
27
31
|
|
28
32
|
class AbstractHistogram(ABC):
|
29
33
|
|
@@ -93,6 +97,43 @@ class AbstractHistogram(ABC):
|
|
93
97
|
"""
|
94
98
|
return np.diff(self._edges[axis])
|
95
99
|
|
100
|
+
def binned_statistics(self, axis: int = 0) -> Tuple[float, float]:
|
101
|
+
"""Return the mean and standard deviation along a specific axis, based
|
102
|
+
on the binned data.
|
103
|
+
|
104
|
+
.. note::
|
105
|
+
|
106
|
+
This is a crude estimate of the underlying statistics that might be
|
107
|
+
useful for monitoring purposes, but should not be relied upon for
|
108
|
+
quantitative analysis.
|
109
|
+
|
110
|
+
This is not the same as computing the mean and standard deviation of
|
111
|
+
the unbinned data that filled the histogram, as some information is
|
112
|
+
lost in the binning process.
|
113
|
+
|
114
|
+
In addition, note that we are not applying any bias correction to
|
115
|
+
the standard deviation, as we are assuming that the histogram is
|
116
|
+
filled with a sufficiently large number of entries. (In most circumstances
|
117
|
+
the effect should be smaller than that of the binning itself.)
|
118
|
+
|
119
|
+
Arguments
|
120
|
+
---------
|
121
|
+
axis : int
|
122
|
+
the axis along which to compute the statistics.
|
123
|
+
|
124
|
+
Returns
|
125
|
+
-------
|
126
|
+
mean : float
|
127
|
+
the mean value along the specified axis.
|
128
|
+
stddev : float
|
129
|
+
the standard deviation along the specified axis.
|
130
|
+
"""
|
131
|
+
values = self.bin_centers(axis)
|
132
|
+
weights = self.content.sum(axis=tuple(i for i in range(self.content.ndim) if i != axis))
|
133
|
+
mean = np.average(values, weights=weights)
|
134
|
+
variance = np.average((values - mean)**2, weights=weights)
|
135
|
+
return float(mean), float(np.sqrt(variance))
|
136
|
+
|
96
137
|
def fill(self, *values: ArrayLike, weights: ArrayLike = None) -> "AbstractHistogram":
|
97
138
|
"""Fill the histogram from unbinned data.
|
98
139
|
|
@@ -229,10 +270,18 @@ class Histogram1d(AbstractHistogram):
|
|
229
270
|
"""
|
230
271
|
# If we are not explicitly providing a label at plotting time, use
|
231
272
|
# the one attached to the histogram, if any.
|
232
|
-
kwargs.setdefault(
|
273
|
+
kwargs.setdefault("label", f"{self}")
|
233
274
|
axes.hist(self.bin_centers(0), self._edges[0], weights=self.content, **kwargs)
|
234
275
|
setup_axes(axes, xlabel=self.axis_labels[0], ylabel=self.axis_labels[1])
|
235
276
|
|
277
|
+
def __str__(self) -> str:
|
278
|
+
"""String formatting.
|
279
|
+
"""
|
280
|
+
mean, rms = self.binned_statistics()
|
281
|
+
text = self.label or self.__class__.__name__
|
282
|
+
text = f"{text}\nMean: {mean:g}\nRMS: {rms:g}"
|
283
|
+
return text
|
284
|
+
|
236
285
|
|
237
286
|
class Histogram2d(AbstractHistogram):
|
238
287
|
|
@@ -259,10 +308,10 @@ class Histogram2d(AbstractHistogram):
|
|
259
308
|
the text label for the z axis (default: "Entries/bin").
|
260
309
|
"""
|
261
310
|
|
262
|
-
DEFAULT_PLOT_OPTIONS = dict(cmap=plt.get_cmap(
|
311
|
+
DEFAULT_PLOT_OPTIONS = dict(cmap=plt.get_cmap("hot"))
|
263
312
|
|
264
313
|
def __init__(self, xedges, yedges, label: str = None, xlabel: str = None,
|
265
|
-
ylabel: str = None, zlabel: str =
|
314
|
+
ylabel: str = None, zlabel: str = "Entries/bin") -> None:
|
266
315
|
"""Constructor.
|
267
316
|
"""
|
268
317
|
super().__init__((xedges, yedges), label, [xlabel, ylabel, zlabel])
|
@@ -272,9 +321,9 @@ class Histogram2d(AbstractHistogram):
|
|
272
321
|
"""
|
273
322
|
# pylint: disable=arguments-differ
|
274
323
|
if logz:
|
275
|
-
vmin = kwargs.pop(
|
276
|
-
vmax = kwargs.pop(
|
277
|
-
kwargs.setdefault(
|
324
|
+
vmin = kwargs.pop("vmin", None)
|
325
|
+
vmax = kwargs.pop("vmax", None)
|
326
|
+
kwargs.setdefault("norm", matplotlib.colors.LogNorm(vmin, vmax))
|
278
327
|
mappable = axes.pcolormesh(*self._edges, self.content.T, **kwargs)
|
279
328
|
plt.colorbar(mappable, ax=axes, label=self.axis_labels[2])
|
280
329
|
setup_axes(axes, xlabel=self.axis_labels[0], ylabel=self.axis_labels[1])
|
@@ -23,17 +23,31 @@ from abc import ABC, abstractmethod
|
|
23
23
|
from dataclasses import dataclass
|
24
24
|
from itertools import chain
|
25
25
|
from numbers import Number
|
26
|
-
from typing import Callable, Iterator, Sequence, Tuple
|
26
|
+
from typing import Callable, Dict, Iterator, Sequence, Tuple
|
27
27
|
|
28
28
|
import matplotlib.pyplot as plt
|
29
29
|
import numpy as np
|
30
30
|
import uncertainties
|
31
|
+
from scipy.integrate import quad
|
31
32
|
from scipy.optimize import curve_fit
|
33
|
+
from scipy.special import erf
|
32
34
|
from scipy.stats import chi2
|
33
35
|
|
34
36
|
from .hist import Histogram1d
|
35
37
|
from .typing_ import ArrayLike
|
36
38
|
|
39
|
+
__all__ = [
|
40
|
+
"Constant",
|
41
|
+
"Line",
|
42
|
+
"Quadratic",
|
43
|
+
"PowerLaw",
|
44
|
+
"Exponential",
|
45
|
+
"Gaussian",
|
46
|
+
"Erf",
|
47
|
+
"ErfInverse",
|
48
|
+
]
|
49
|
+
|
50
|
+
# pylint: disable=too-many-lines
|
37
51
|
|
38
52
|
class Format(str, enum.Enum):
|
39
53
|
|
@@ -277,6 +291,17 @@ class FitStatus:
|
|
277
291
|
self.pvalue = None
|
278
292
|
self.fit_range = None
|
279
293
|
|
294
|
+
def valid(self) -> bool:
|
295
|
+
"""Return True if the fit status is valid, i.e., if the chisquare,
|
296
|
+
dof, and pvalue are all set.
|
297
|
+
|
298
|
+
Returns
|
299
|
+
-------
|
300
|
+
valid : bool
|
301
|
+
True if the fit status is valid.
|
302
|
+
"""
|
303
|
+
return self.chisquare is not None and self.dof is not None and self.pvalue is not None
|
304
|
+
|
280
305
|
def update(self, chisquare: float, dof: int = None) -> None:
|
281
306
|
"""Update the fit status, i.e., set the chisquare and calculate the
|
282
307
|
corresponding p-value.
|
@@ -316,10 +341,10 @@ class FitStatus:
|
|
316
341
|
if self.chisquare is None:
|
317
342
|
return "N/A"
|
318
343
|
if spec.endswith(Format.LATEX):
|
319
|
-
return f"$\\chi^2
|
344
|
+
return f"$\\chi^2$: {self.chisquare:.2f} / {self.dof} dof"
|
320
345
|
if spec.endswith(Format.PRETTY):
|
321
|
-
return f"
|
322
|
-
return f"chisquare
|
346
|
+
return f"χ²: {self.chisquare:.2f} / {self.dof} dof"
|
347
|
+
return f"chisquare: {self.chisquare:.2f} / {self.dof} dof"
|
323
348
|
|
324
349
|
def __str__(self) -> str:
|
325
350
|
"""String formatting.
|
@@ -776,7 +801,7 @@ class AbstractFitModelBase(ABC):
|
|
776
801
|
The formatted string.
|
777
802
|
"""
|
778
803
|
text = f"{self.name()}\n"
|
779
|
-
if self.status
|
804
|
+
if self.status.valid():
|
780
805
|
text = f"{text}{format(self.status, spec)}\n"
|
781
806
|
for parameter in self:
|
782
807
|
text = f"{text}{format(parameter, spec)}\n"
|
@@ -807,13 +832,47 @@ class AbstractFitModel(AbstractFitModelBase):
|
|
807
832
|
"""
|
808
833
|
super().__init__()
|
809
834
|
self._parameters = []
|
810
|
-
|
811
|
-
|
812
|
-
|
813
|
-
|
814
|
-
|
815
|
-
|
816
|
-
|
835
|
+
# Note we cannot loop over self.__dict__.items() here, as that would
|
836
|
+
# only return the members defined in the actual class, and not the
|
837
|
+
# inherited ones.
|
838
|
+
for name, value in self.__class__._parameter_dict().items():
|
839
|
+
parameter = value.copy(name)
|
840
|
+
# Note we also set one instance attribute for each parameter so
|
841
|
+
# that we can use the notation model.parameter
|
842
|
+
setattr(self, name, parameter)
|
843
|
+
self._parameters.append(parameter)
|
844
|
+
|
845
|
+
@classmethod
|
846
|
+
def _parameter_dict(cls) -> Dict[str, FitParameter]:
|
847
|
+
"""Return a dictionary of all the FitParameter objects defined in the class
|
848
|
+
and its base classes.
|
849
|
+
|
850
|
+
This is a subtle one, as what we really want, here, is all members of a class
|
851
|
+
(including inherited ones) that are of a specific type (FitParameter), in the
|
852
|
+
order they were defined. All of these thing are instrumental to make the
|
853
|
+
fit model work, so we need to be careful.
|
854
|
+
|
855
|
+
Also note the we are looping over the MRO in reverse order, so that we
|
856
|
+
preserve the order of definition of the parameters, even when they are
|
857
|
+
inherited from base classes. If a parameter is re-defined in a derived class,
|
858
|
+
the derived class definition takes precedence, as we are using a dictionary
|
859
|
+
to collect the parameters.
|
860
|
+
|
861
|
+
Arguments
|
862
|
+
---------
|
863
|
+
cls : type
|
864
|
+
The class to inspect.
|
865
|
+
|
866
|
+
Returns
|
867
|
+
-------
|
868
|
+
param_dict : dict
|
869
|
+
A dictionary mapping parameter names to their FitParameter objects.
|
870
|
+
"""
|
871
|
+
param_dict = {}
|
872
|
+
for base in reversed(cls.__mro__):
|
873
|
+
param_dict.update({name: value for name, value in base.__dict__.items() if
|
874
|
+
isinstance(value, FitParameter)})
|
875
|
+
return param_dict
|
817
876
|
|
818
877
|
def __len__(self) -> int:
|
819
878
|
"""Return the `total` number of fit parameters in the model.
|
@@ -832,6 +891,46 @@ class AbstractFitModel(AbstractFitModelBase):
|
|
832
891
|
raise TypeError(f"{other} is not a fit model")
|
833
892
|
return FitModelSum(self, other)
|
834
893
|
|
894
|
+
def quadrature(self, xmin: float, xmax: float) -> float:
|
895
|
+
"""Calculate the integral of the model between xmin and xmax using
|
896
|
+
numerical integration.
|
897
|
+
|
898
|
+
Arguments
|
899
|
+
---------
|
900
|
+
xmin : float
|
901
|
+
The minimum value of the independent variable to integrate over.
|
902
|
+
|
903
|
+
xmax : float
|
904
|
+
The maximum value of the independent variable to integrate over.
|
905
|
+
|
906
|
+
Returns
|
907
|
+
-------
|
908
|
+
integral : float
|
909
|
+
The integral of the model between xmin and xmax.
|
910
|
+
"""
|
911
|
+
value, _ = quad(self, xmin, xmax)
|
912
|
+
return value
|
913
|
+
|
914
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
915
|
+
"""Default implementation of the integral of the model between xmin and xmax.
|
916
|
+
Subclasses can (and are encouraged to) overload this method with an
|
917
|
+
analytical implementation, when available.
|
918
|
+
|
919
|
+
Arguments
|
920
|
+
---------
|
921
|
+
xmin : float
|
922
|
+
The minimum value of the independent variable to integrate over.
|
923
|
+
|
924
|
+
xmax : float
|
925
|
+
The maximum value of the independent variable to integrate over.
|
926
|
+
|
927
|
+
Returns
|
928
|
+
-------
|
929
|
+
integral : float
|
930
|
+
The integral of the model between xmin and xmax.
|
931
|
+
"""
|
932
|
+
return self.quadrature(xmin, xmax)
|
933
|
+
|
835
934
|
|
836
935
|
class FitModelSum(AbstractFitModelBase):
|
837
936
|
|
@@ -878,6 +977,26 @@ class FitModelSum(AbstractFitModelBase):
|
|
878
977
|
cursor += len(component)
|
879
978
|
return value
|
880
979
|
|
980
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
981
|
+
"""Calculate the integral of the model between xmin and xmax.
|
982
|
+
|
983
|
+
This is implemented as the sum of the integrals of the components.
|
984
|
+
|
985
|
+
Arguments
|
986
|
+
---------
|
987
|
+
xmin : float
|
988
|
+
The minimum value of the independent variable to integrate over.
|
989
|
+
|
990
|
+
xmax : float
|
991
|
+
The maximum value of the independent variable to integrate over.
|
992
|
+
|
993
|
+
Returns
|
994
|
+
-------
|
995
|
+
integral : float
|
996
|
+
The integral of the model between xmin and xmax.
|
997
|
+
"""
|
998
|
+
return sum(component.integral(xmin, xmax) for component in self._components)
|
999
|
+
|
881
1000
|
def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
|
882
1001
|
"""Overloaded method for plotting the model.
|
883
1002
|
"""
|
@@ -931,8 +1050,13 @@ class Constant(AbstractFitModel):
|
|
931
1050
|
@staticmethod
|
932
1051
|
def evaluate(x: ArrayLike, value: float) -> ArrayLike:
|
933
1052
|
# pylint: disable=arguments-differ
|
1053
|
+
if isinstance(x, Number):
|
1054
|
+
return value
|
934
1055
|
return np.full(x.shape, value)
|
935
1056
|
|
1057
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
1058
|
+
return self.value.value * (xmax - xmin)
|
1059
|
+
|
936
1060
|
|
937
1061
|
class Line(AbstractFitModel):
|
938
1062
|
|
@@ -947,6 +1071,29 @@ class Line(AbstractFitModel):
|
|
947
1071
|
# pylint: disable=arguments-differ
|
948
1072
|
return slope * x + intercept
|
949
1073
|
|
1074
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
1075
|
+
slope, intercept = self.parameter_values()
|
1076
|
+
return 0.5 * slope * (xmax**2 - xmin**2) + intercept * (xmax - xmin)
|
1077
|
+
|
1078
|
+
|
1079
|
+
class Quadratic(AbstractFitModel):
|
1080
|
+
|
1081
|
+
"""Quadratic model.
|
1082
|
+
"""
|
1083
|
+
|
1084
|
+
a = FitParameter(1.)
|
1085
|
+
b = FitParameter(1.)
|
1086
|
+
c = FitParameter(0.)
|
1087
|
+
|
1088
|
+
@staticmethod
|
1089
|
+
def evaluate(x: ArrayLike, a: float, b: float, c: float) -> ArrayLike:
|
1090
|
+
# pylint: disable=arguments-differ
|
1091
|
+
return a * x**2 + b * x + c
|
1092
|
+
|
1093
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
1094
|
+
a, b, c = self.parameter_values()
|
1095
|
+
return a * (xmax**3 - xmin**3) / 3. + b * (xmax**2 - xmin**2) / 2. + c * (xmax - xmin)
|
1096
|
+
|
950
1097
|
|
951
1098
|
class PowerLaw(AbstractFitModel):
|
952
1099
|
|
@@ -954,33 +1101,88 @@ class PowerLaw(AbstractFitModel):
|
|
954
1101
|
"""
|
955
1102
|
|
956
1103
|
prefactor = FitParameter(1.)
|
957
|
-
index = FitParameter(-
|
1104
|
+
index = FitParameter(-2.)
|
958
1105
|
|
959
1106
|
@staticmethod
|
960
1107
|
def evaluate(x: ArrayLike, prefactor: float, index: float) -> ArrayLike:
|
961
1108
|
# pylint: disable=arguments-differ
|
962
1109
|
return prefactor * x**index
|
963
1110
|
|
1111
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
1112
|
+
prefactor, index = self.parameter_values()
|
1113
|
+
if index == -1.:
|
1114
|
+
return prefactor * np.log(xmax / xmin)
|
1115
|
+
return prefactor / (index + 1.) * (xmax**(index + 1.) - xmin**(index + 1.))
|
964
1116
|
|
965
|
-
|
1117
|
+
def default_plotting_range(self) -> Tuple[float, float]:
|
1118
|
+
return (0.1, 10.)
|
966
1119
|
|
967
|
-
|
1120
|
+
def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
|
1121
|
+
super().plot(xmin, xmax, num_points)
|
1122
|
+
plt.xscale("log")
|
1123
|
+
plt.yscale("log")
|
1124
|
+
|
1125
|
+
|
1126
|
+
class Exponential(AbstractFitModel):
|
1127
|
+
|
1128
|
+
"""Exponential model.
|
1129
|
+
"""
|
1130
|
+
|
1131
|
+
prefactor = FitParameter(1.)
|
1132
|
+
scale = FitParameter(1.)
|
1133
|
+
|
1134
|
+
@staticmethod
|
1135
|
+
def evaluate(x: ArrayLike, prefactor: float, scale: float) -> ArrayLike:
|
1136
|
+
# pylint: disable=arguments-differ
|
1137
|
+
return prefactor * np.exp(-x / scale)
|
1138
|
+
|
1139
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
1140
|
+
prefactor, scale = self.parameter_values()
|
1141
|
+
return prefactor * scale * (np.exp(-xmin / scale) - np.exp(-xmax / scale))
|
1142
|
+
|
1143
|
+
def default_plotting_range(self, scale_factor: int = 5) -> Tuple[float, float]:
|
1144
|
+
return (0., scale_factor * self.scale.value)
|
1145
|
+
|
1146
|
+
|
1147
|
+
class _GaussianBase(AbstractFitModel):
|
1148
|
+
|
1149
|
+
"""Common base class for Gaussian-like models.
|
1150
|
+
|
1151
|
+
This provides a couple of convenience methods that are useful for all the
|
1152
|
+
models derived from a gaussian (e.g., the gaussian itself, the error function,
|
1153
|
+
and its inverse). Note that, for the right method to be picked up,
|
1154
|
+
subclasses should derive from this class *before* deriving from
|
1155
|
+
AbstractFitModel, so that the method resolution order (MRO) works as expected.
|
1156
|
+
|
1157
|
+
Note the evaluate() method is not implemented here, which means that the class
|
1158
|
+
cannot be instantiated directly.
|
968
1159
|
"""
|
969
1160
|
|
970
1161
|
prefactor = FitParameter(1.)
|
971
1162
|
mean = FitParameter(0.)
|
972
1163
|
sigma = FitParameter(1., minimum=0.)
|
973
1164
|
|
1165
|
+
# A few useful constants.
|
1166
|
+
_SQRT2 = np.sqrt(2.)
|
974
1167
|
_NORM_CONSTANT = 1. / np.sqrt(2. * np.pi)
|
975
1168
|
_SIGMA_TO_FWHM = 2. * np.sqrt(2. * np.log(2.))
|
976
1169
|
|
977
|
-
@staticmethod
|
978
|
-
def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
|
979
|
-
# pylint: disable=arguments-differ
|
980
|
-
z = (x - mean) / sigma
|
981
|
-
return prefactor * Gaussian._NORM_CONSTANT / sigma * np.exp(-0.5 * z**2.)
|
982
|
-
|
983
1170
|
def default_plotting_range(self, num_sigma: int = 5) -> Tuple[float, float]:
|
1171
|
+
"""Convenience function to return a default plotting range for all the
|
1172
|
+
models derived from a gaussian (e.g., the gaussian itself, the error
|
1173
|
+
function, and its inverse).
|
1174
|
+
|
1175
|
+
Arguments
|
1176
|
+
---------
|
1177
|
+
num_sigma : int, optional
|
1178
|
+
The number of sigmas to use for the plotting range (default 5).
|
1179
|
+
|
1180
|
+
Returns
|
1181
|
+
-------
|
1182
|
+
Tuple[float, float]
|
1183
|
+
The default plotting range for the model.
|
1184
|
+
"""
|
1185
|
+
# pylint: disable=no-member
|
984
1186
|
mean, half_width = self.mean.value, num_sigma * self.sigma.value
|
985
1187
|
return (mean - half_width, mean + half_width)
|
986
1188
|
|
@@ -992,4 +1194,46 @@ class Gaussian(AbstractFitModel):
|
|
992
1194
|
fwhm : uncertainties.ufloat
|
993
1195
|
The FWHM of the gaussian.
|
994
1196
|
"""
|
1197
|
+
# pylint: disable=no-member
|
995
1198
|
return self.sigma.ufloat() * self._SIGMA_TO_FWHM
|
1199
|
+
|
1200
|
+
|
1201
|
+
class Gaussian(_GaussianBase):
|
1202
|
+
|
1203
|
+
"""Gaussian model.
|
1204
|
+
"""
|
1205
|
+
|
1206
|
+
@staticmethod
|
1207
|
+
def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
|
1208
|
+
# pylint: disable=arguments-differ
|
1209
|
+
z = (x - mean) / sigma
|
1210
|
+
return prefactor * _GaussianBase._NORM_CONSTANT / sigma * np.exp(-0.5 * z**2.)
|
1211
|
+
|
1212
|
+
def integral(self, xmin: float, xmax: float) -> float:
|
1213
|
+
prefactor, mean, sigma = self.parameter_values()
|
1214
|
+
zmin = (xmin - mean) / (sigma * self._SQRT2)
|
1215
|
+
zmax = (xmax - mean) / (sigma * self._SQRT2)
|
1216
|
+
return prefactor * 0.5 * (erf(zmax) - erf(zmin))
|
1217
|
+
|
1218
|
+
|
1219
|
+
class Erf(_GaussianBase):
|
1220
|
+
|
1221
|
+
"""Error function model.
|
1222
|
+
"""
|
1223
|
+
|
1224
|
+
@staticmethod
|
1225
|
+
def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
|
1226
|
+
# pylint: disable=arguments-differ
|
1227
|
+
z = (x - mean) / sigma
|
1228
|
+
return prefactor * 0.5 * (1. + erf(z / _GaussianBase._SQRT2))
|
1229
|
+
|
1230
|
+
|
1231
|
+
class ErfInverse(_GaussianBase):
|
1232
|
+
|
1233
|
+
"""Inverse error function model.
|
1234
|
+
"""
|
1235
|
+
|
1236
|
+
@staticmethod
|
1237
|
+
def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
|
1238
|
+
# pylint: disable=arguments-differ
|
1239
|
+
return prefactor - Erf.evaluate(x, prefactor, mean, sigma)
|
@@ -101,22 +101,43 @@ def test_plotting1d(size: int = 100000):
|
|
101
101
|
plt.figure(inspect.currentframe().f_code.co_name)
|
102
102
|
# Create the first histogram. This has no label attached, so we will have to
|
103
103
|
# provide one at plotting time, if we want to have a corresponding legend entry.
|
104
|
+
mean = 0.
|
105
|
+
sigma = 1.
|
104
106
|
hist1 = Histogram1d(np.linspace(-5., 5., 100), xlabel='x')
|
105
|
-
hist1.fill(_RNG.normal(size=size))
|
107
|
+
hist1.fill(_RNG.normal(size=size, loc=mean, scale=sigma))
|
106
108
|
hist1.plot(label='Standard histogram')
|
109
|
+
m, s = hist1.binned_statistics()
|
110
|
+
# Rough checks on the binned statistics---we want the mean to be within 10
|
111
|
+
# sigma/sqrt(N) and the stddev to be within 2% of the true value.
|
112
|
+
# (Note the binning has an effect on the actual values, so we cannot
|
113
|
+
# expect perfect agreement.)
|
114
|
+
assert abs((m - mean) / sigma * np.sqrt(size)) < 10.
|
115
|
+
assert abs(s / sigma - 1.) < 0.02
|
116
|
+
|
107
117
|
# Create a second histogram, this time with a label---this should have a
|
108
118
|
# proper entry in the legend automatically.
|
119
|
+
mean = 1.
|
120
|
+
sigma = 1.5
|
109
121
|
hist2 = Histogram1d(np.linspace(-5., 5., 100), label='Offset histogram')
|
110
|
-
hist2.fill(_RNG.normal(size=size, loc=
|
122
|
+
hist2.fill(_RNG.normal(size=size, loc=mean, scale=sigma))
|
111
123
|
hist2.plot()
|
124
|
+
m, s = hist2.binned_statistics()
|
125
|
+
assert abs((m - mean) / sigma * np.sqrt(size)) < 10.
|
126
|
+
assert abs(s / sigma - 1.) < 0.02
|
127
|
+
|
112
128
|
# And this one should end up with no legend entry, as it has no label
|
129
|
+
mean = -1.
|
130
|
+
sigma = 0.5
|
113
131
|
hist3 = Histogram1d(np.linspace(-5., 5., 100))
|
114
|
-
hist3.fill(_RNG.normal(size=size
|
132
|
+
hist3.fill(_RNG.normal(size=size, loc=mean, scale=sigma))
|
115
133
|
hist3.plot()
|
134
|
+
m, s = hist3.binned_statistics()
|
135
|
+
assert abs((m - mean) / sigma * np.sqrt(size)) < 10.
|
136
|
+
assert abs(s / sigma - 1.) < 0.02
|
116
137
|
plt.legend()
|
117
138
|
|
118
139
|
|
119
|
-
def test_plotting2d(size: int = 100000):
|
140
|
+
def test_plotting2d(size: int = 100000, x0: float = 1., y0: float = -1.):
|
120
141
|
"""Test plotting.
|
121
142
|
"""
|
122
143
|
plt.figure(inspect.currentframe().f_code.co_name)
|
@@ -124,8 +145,14 @@ def test_plotting2d(size: int = 100000):
|
|
124
145
|
hist = Histogram2d(edges, edges, 'x', 'y')
|
125
146
|
# Note we are adding different offsets to x and y so that we can see
|
126
147
|
# the effect on the plot.
|
127
|
-
hist.fill(_RNG.normal(size=size)
|
148
|
+
hist.fill(_RNG.normal(size=size, loc=x0), _RNG.normal(size=size, loc=y0))
|
128
149
|
hist.plot()
|
150
|
+
mx, sx = hist.binned_statistics(0)
|
151
|
+
my, sy = hist.binned_statistics(1)
|
152
|
+
assert abs((mx - x0) * np.sqrt(size)) < 10.
|
153
|
+
assert abs((my - y0) * np.sqrt(size)) < 10.
|
154
|
+
assert abs(sx - 1.) < 0.02
|
155
|
+
assert abs(sy - 1.) < 0.02
|
129
156
|
plt.gca().set_aspect('equal')
|
130
157
|
|
131
158
|
|
@@ -19,14 +19,25 @@
|
|
19
19
|
import inspect
|
20
20
|
|
21
21
|
import numpy as np
|
22
|
+
import pytest
|
22
23
|
|
23
24
|
from aptapy.hist import Histogram1d
|
24
|
-
from aptapy.modeling import
|
25
|
+
from aptapy.modeling import (
|
26
|
+
Constant,
|
27
|
+
Erf,
|
28
|
+
ErfInverse,
|
29
|
+
Exponential,
|
30
|
+
FitParameter,
|
31
|
+
Gaussian,
|
32
|
+
Line,
|
33
|
+
PowerLaw,
|
34
|
+
Quadratic,
|
35
|
+
)
|
25
36
|
from aptapy.plotting import plt
|
26
37
|
|
27
38
|
_RNG = np.random.default_rng(313)
|
28
39
|
|
29
|
-
TEST_HISTOGRAM = Histogram1d(np.linspace(-5., 5., 100), label="
|
40
|
+
TEST_HISTOGRAM = Histogram1d(np.linspace(-5., 5., 100), label="Random data")
|
30
41
|
TEST_HISTOGRAM.fill(_RNG.normal(size=100000))
|
31
42
|
NUM_SIGMA = 4.
|
32
43
|
|
@@ -82,6 +93,89 @@ def test_model_parameters():
|
|
82
93
|
assert id(p1) != id(p2)
|
83
94
|
|
84
95
|
|
96
|
+
def test_plot():
|
97
|
+
"""Test the plot method of the models.
|
98
|
+
"""
|
99
|
+
for model in (Constant(), Line(), Quadratic(), PowerLaw(), Exponential(),
|
100
|
+
Gaussian(), Erf(), ErfInverse()):
|
101
|
+
plt.figure(f"{inspect.currentframe().f_code.co_name}_{model.__class__.__name__}")
|
102
|
+
model.plot()
|
103
|
+
plt.legend()
|
104
|
+
|
105
|
+
|
106
|
+
def test_integral():
|
107
|
+
"""Test the integral method of the models.
|
108
|
+
"""
|
109
|
+
# pylint: disable=too-many-statements
|
110
|
+
# Constant.
|
111
|
+
xmin = 0.
|
112
|
+
xmax = 1.
|
113
|
+
value = 1.
|
114
|
+
target = value * (xmax - xmin)
|
115
|
+
model = Constant()
|
116
|
+
model.value.freeze(1.)
|
117
|
+
assert model.quadrature(xmin, xmax) == pytest.approx(target)
|
118
|
+
assert model.integral(xmin, xmax) == pytest.approx(target)
|
119
|
+
# Line.
|
120
|
+
slope = 1.
|
121
|
+
intercept = 1.
|
122
|
+
target = 0.5 * slope * (xmax**2 - xmin**2) + intercept * (xmax - xmin)
|
123
|
+
model = Line()
|
124
|
+
model.slope.freeze(slope)
|
125
|
+
model.intercept.freeze(intercept)
|
126
|
+
assert model.quadrature(xmin, xmax) == pytest.approx(target)
|
127
|
+
assert model.integral(xmin, xmax) == pytest.approx(target)
|
128
|
+
# Quadratic.
|
129
|
+
a = 1.
|
130
|
+
b = 1.
|
131
|
+
c = 1.
|
132
|
+
target = a * (xmax**3 - xmin**3) / 3. + b * (xmax**2 - xmin**2) / 2. + c * (xmax - xmin)
|
133
|
+
model = Quadratic()
|
134
|
+
model.a.freeze(a)
|
135
|
+
model.b.freeze(b)
|
136
|
+
model.c.freeze(c)
|
137
|
+
assert model.quadrature(xmin, xmax) == pytest.approx(target)
|
138
|
+
assert model.integral(xmin, xmax) == pytest.approx(target)
|
139
|
+
# PowerLaw.
|
140
|
+
xmin = 1.
|
141
|
+
xmax = 10.
|
142
|
+
prefactor = 1.
|
143
|
+
for index in (-2., -1.):
|
144
|
+
if index == -1.:
|
145
|
+
target = prefactor * np.log(xmax / xmin)
|
146
|
+
else:
|
147
|
+
target = prefactor / (index + 1.) * (xmax**(index + 1.) - xmin**(index + 1.))
|
148
|
+
model = PowerLaw()
|
149
|
+
model.prefactor.freeze(prefactor)
|
150
|
+
model.index.freeze(index)
|
151
|
+
assert model.quadrature(xmin, xmax) == pytest.approx(target)
|
152
|
+
assert model.integral(xmin, xmax) == pytest.approx(target)
|
153
|
+
# Exponential.
|
154
|
+
xmin = 0.
|
155
|
+
xmax = 10.
|
156
|
+
prefactor = 1.
|
157
|
+
scale = 1.
|
158
|
+
target = prefactor * scale * (np.exp(-xmin / scale) - np.exp(-xmax / scale))
|
159
|
+
model = Exponential()
|
160
|
+
model.prefactor.freeze(prefactor)
|
161
|
+
model.scale.freeze(scale)
|
162
|
+
assert model.quadrature(xmin, xmax) == pytest.approx(target)
|
163
|
+
assert model.integral(xmin, xmax) == pytest.approx(target)
|
164
|
+
# Gaussian.
|
165
|
+
xmin = -5.
|
166
|
+
xmax = 5.
|
167
|
+
prefactor = 1.
|
168
|
+
mean = 0.
|
169
|
+
sigma = 1.
|
170
|
+
target = 1.
|
171
|
+
model = Gaussian()
|
172
|
+
model.prefactor.freeze(prefactor)
|
173
|
+
model.mean.freeze(mean)
|
174
|
+
model.sigma.freeze(sigma)
|
175
|
+
assert model.quadrature(xmin, xmax) == pytest.approx(target)
|
176
|
+
assert model.integral(xmin, xmax) == pytest.approx(target)
|
177
|
+
|
178
|
+
|
85
179
|
def test_gaussian_fit():
|
86
180
|
"""Simple Gaussian fit.
|
87
181
|
"""
|
@@ -181,6 +275,7 @@ def test_multiple_sum():
|
|
181
275
|
|
182
276
|
|
183
277
|
if __name__ == '__main__':
|
278
|
+
test_plot()
|
184
279
|
test_gaussian_fit()
|
185
280
|
test_gaussian_fit_subrange()
|
186
281
|
test_gaussian_fit_bound()
|
@@ -1 +0,0 @@
|
|
1
|
-
__version__ = "0.3.1"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|