aptapy 0.1.1__py3-none-any.whl → 0.3.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.
- aptapy/_version.py +1 -1
- aptapy/hist.py +280 -0
- aptapy/modeling.py +576 -96
- aptapy/plotting.py +334 -290
- aptapy/strip.py +92 -0
- {aptapy-0.1.1.dist-info → aptapy-0.3.0.dist-info}/METADATA +10 -2
- aptapy-0.3.0.dist-info/RECORD +12 -0
- aptapy-0.1.1.dist-info/RECORD +0 -10
- {aptapy-0.1.1.dist-info → aptapy-0.3.0.dist-info}/WHEEL +0 -0
- {aptapy-0.1.1.dist-info → aptapy-0.3.0.dist-info}/licenses/LICENSE +0 -0
aptapy/modeling.py
CHANGED
@@ -21,15 +21,18 @@ import functools
|
|
21
21
|
import inspect
|
22
22
|
from abc import ABC, abstractmethod
|
23
23
|
from dataclasses import dataclass
|
24
|
+
from itertools import chain
|
24
25
|
from numbers import Number
|
25
|
-
from typing import Iterator, Tuple
|
26
|
+
from typing import Callable, Iterator, Sequence, Tuple
|
26
27
|
|
27
28
|
import matplotlib.pyplot as plt
|
28
29
|
import numpy as np
|
29
30
|
import uncertainties
|
30
31
|
from scipy.optimize import curve_fit
|
32
|
+
from scipy.stats import chi2
|
31
33
|
|
32
|
-
from
|
34
|
+
from .hist import Histogram1d
|
35
|
+
from .typing_ import ArrayLike
|
33
36
|
|
34
37
|
|
35
38
|
class Format(str, enum.Enum):
|
@@ -37,11 +40,11 @@ class Format(str, enum.Enum):
|
|
37
40
|
"""Small enum class to control string formatting.
|
38
41
|
|
39
42
|
This is leveraging the custom formatting of the uncertainties package, where
|
40
|
-
a trailing
|
43
|
+
a trailing `P` means "pretty print" and a trailing `L` means "LaTeX".
|
41
44
|
"""
|
42
45
|
|
43
|
-
PRETTY =
|
44
|
-
LATEX =
|
46
|
+
PRETTY = "P"
|
47
|
+
LATEX = "L"
|
45
48
|
|
46
49
|
|
47
50
|
@dataclass
|
@@ -63,6 +66,11 @@ class FitParameter:
|
|
63
66
|
|
64
67
|
We are wrapping this into a property because, arguably, the parameter name is
|
65
68
|
the only thing we never, ever want to change after the fact.
|
69
|
+
|
70
|
+
Returns
|
71
|
+
-------
|
72
|
+
name : str
|
73
|
+
The parameter name.
|
66
74
|
"""
|
67
75
|
return self._name
|
68
76
|
|
@@ -72,15 +80,25 @@ class FitParameter:
|
|
72
80
|
|
73
81
|
We are wrapping this into a property because we interact with this member
|
74
82
|
via the freeze() and thaw() methods.
|
83
|
+
|
84
|
+
Returns
|
85
|
+
-------
|
86
|
+
frozen : bool
|
87
|
+
True if the parameter is frozen.
|
75
88
|
"""
|
76
89
|
return self._frozen
|
77
90
|
|
78
91
|
def is_bound(self) -> bool:
|
79
92
|
"""Return True if the parameter is bounded.
|
93
|
+
|
94
|
+
Returns
|
95
|
+
-------
|
96
|
+
bounded : bool
|
97
|
+
True if the parameter is bounded.
|
80
98
|
"""
|
81
99
|
return not np.isinf(self.minimum) or not np.isinf(self.maximum)
|
82
100
|
|
83
|
-
def copy(self, name: str) ->
|
101
|
+
def copy(self, name: str) -> "FitParameter":
|
84
102
|
"""Create a copy of the parameter object with a new name.
|
85
103
|
|
86
104
|
This is necessary because we define the fit parameters of the actual model as
|
@@ -94,7 +112,12 @@ class FitParameter:
|
|
94
112
|
Arguments
|
95
113
|
---------
|
96
114
|
name : str
|
97
|
-
The name for the new FitParameter object.
|
115
|
+
The name for the new :class:`FitParameter` object.
|
116
|
+
|
117
|
+
Returns
|
118
|
+
-------
|
119
|
+
parameter : FitParameter
|
120
|
+
The new :class:`FitParameter` object.
|
98
121
|
"""
|
99
122
|
return self.__class__(self.value, name, minimum=self.minimum, maximum=self.maximum)
|
100
123
|
|
@@ -132,31 +155,92 @@ class FitParameter:
|
|
132
155
|
|
133
156
|
def ufloat(self) -> uncertainties.ufloat:
|
134
157
|
"""Return the parameter value and error as a ufloat object.
|
158
|
+
|
159
|
+
Returns
|
160
|
+
-------
|
161
|
+
ufloat : uncertainties.ufloat
|
162
|
+
The parameter value and error as a ufloat object.
|
135
163
|
"""
|
136
164
|
return uncertainties.ufloat(self.value, self.error)
|
137
165
|
|
166
|
+
def pull(self, expected: float) -> float:
|
167
|
+
"""Calculate the pull of the parameter with respect to a given expected value.
|
168
|
+
|
169
|
+
Arguments
|
170
|
+
---------
|
171
|
+
expected : float
|
172
|
+
The expected value for the parameter.
|
173
|
+
|
174
|
+
Returns
|
175
|
+
-------
|
176
|
+
pull : float
|
177
|
+
The pull of the parameter with respect to the expected value, defined as
|
178
|
+
(value - expected) / error.
|
179
|
+
|
180
|
+
Raises
|
181
|
+
------
|
182
|
+
RuntimeError
|
183
|
+
If the parameter has no error associated to it.
|
184
|
+
"""
|
185
|
+
if self.error is None or self.error <= 0.:
|
186
|
+
raise RuntimeError(f"Cannot calculate pull for parameter {self.name} "
|
187
|
+
"with no error")
|
188
|
+
return (self.value - expected) / self.error
|
189
|
+
|
190
|
+
def compatible_with(self, expected: float, num_sigma: float = 3.) -> bool:
|
191
|
+
"""Check if the parameter is compatible with an expected value within
|
192
|
+
n_sigma.
|
193
|
+
|
194
|
+
Arguments
|
195
|
+
---------
|
196
|
+
expected : float
|
197
|
+
The expected value for the parameter.
|
198
|
+
|
199
|
+
num_sigma : float, optional
|
200
|
+
The number of sigmas to use for the compatibility check (default 3).
|
201
|
+
|
202
|
+
Returns
|
203
|
+
-------
|
204
|
+
compatible : bool
|
205
|
+
True if the parameter is compatible with the expected value within
|
206
|
+
num_sigma.
|
207
|
+
"""
|
208
|
+
return abs(self.pull(expected)) <= num_sigma
|
209
|
+
|
138
210
|
def __format__(self, spec: str) -> str:
|
139
211
|
"""String formatting.
|
212
|
+
|
213
|
+
Arguments
|
214
|
+
---------
|
215
|
+
spec : str
|
216
|
+
The format specification.
|
217
|
+
|
218
|
+
Returns
|
219
|
+
-------
|
220
|
+
text : str
|
221
|
+
The formatted string.
|
140
222
|
"""
|
141
223
|
# Keep in mind Python passes an empty string explicitly when you call
|
142
|
-
# f
|
224
|
+
# f"{parameter}", so we can't really assign a default value to spec.
|
143
225
|
if self.error is not None:
|
144
226
|
param = format(self.ufloat(), spec)
|
145
227
|
if spec.endswith(Format.LATEX):
|
146
|
-
param = f
|
228
|
+
param = f"${param}$"
|
147
229
|
else:
|
148
|
-
spec
|
149
|
-
|
150
|
-
|
230
|
+
# Note in this case we are not passing the format spec to format(), as
|
231
|
+
# the only thing we can do in absence of an error is to use the
|
232
|
+
# Python default formatting.
|
233
|
+
param = format(self.value, "g")
|
234
|
+
text = f"{self._name.title()}: {param}"
|
151
235
|
info = []
|
152
236
|
if self._frozen:
|
153
|
-
info.append(
|
237
|
+
info.append("frozen")
|
154
238
|
if not np.isinf(self.minimum):
|
155
|
-
info.append(f
|
239
|
+
info.append(f"min={self.minimum}")
|
156
240
|
if not np.isinf(self.maximum):
|
157
|
-
info.append(f
|
241
|
+
info.append(f"max={self.maximum}")
|
158
242
|
if info:
|
159
|
-
text = f
|
243
|
+
text = f"{text} ({', '.join(info)})"
|
160
244
|
return text
|
161
245
|
|
162
246
|
def __str__(self) -> str:
|
@@ -165,6 +249,11 @@ class FitParameter:
|
|
165
249
|
This is meant to provide a more human-readable version of the parameter formatting
|
166
250
|
than the default ``__repr__`` implementation from the dataclass decorator, and it
|
167
251
|
is what is used in the actual printout of the fit parameters from a fit.
|
252
|
+
|
253
|
+
Returns
|
254
|
+
-------
|
255
|
+
text : str
|
256
|
+
The formatted string.
|
168
257
|
"""
|
169
258
|
return format(self, Format.PRETTY)
|
170
259
|
|
@@ -177,7 +266,7 @@ class FitStatus:
|
|
177
266
|
|
178
267
|
chisquare: float = None
|
179
268
|
dof: int = None
|
180
|
-
|
269
|
+
pvalue: float = None
|
181
270
|
fit_range: Tuple[float, float] = None
|
182
271
|
|
183
272
|
def reset(self) -> None:
|
@@ -185,134 +274,276 @@ class FitStatus:
|
|
185
274
|
"""
|
186
275
|
self.chisquare = None
|
187
276
|
self.dof = None
|
277
|
+
self.pvalue = None
|
188
278
|
self.fit_range = None
|
189
279
|
|
280
|
+
def update(self, chisquare: float, dof: int = None) -> None:
|
281
|
+
"""Update the fit status, i.e., set the chisquare and calculate the
|
282
|
+
corresponding p-value.
|
283
|
+
|
284
|
+
Arguments
|
285
|
+
---------
|
286
|
+
chisquare : float
|
287
|
+
The chisquare of the fit.
|
288
|
+
|
289
|
+
dof : int, optional
|
290
|
+
The number of degrees of freedom of the fit.
|
291
|
+
"""
|
292
|
+
self.chisquare = chisquare
|
293
|
+
if dof is not None:
|
294
|
+
self.dof = dof
|
295
|
+
self.pvalue = chi2.sf(self.chisquare, self.dof)
|
296
|
+
# chi2.sf() returns the survival function, i.e., 1 - cdf. If the survival
|
297
|
+
# function is > 0.5, we flip it around, so that we always report the smallest
|
298
|
+
# tail, and the pvalue is the probability of obtaining a chisquare value more
|
299
|
+
# `extreme` of the one we got.
|
300
|
+
if self.pvalue > 0.5:
|
301
|
+
self.pvalue = 1. - self.pvalue
|
302
|
+
|
190
303
|
def __format__(self, spec: str) -> str:
|
191
304
|
"""String formatting.
|
305
|
+
|
306
|
+
Arguments
|
307
|
+
---------
|
308
|
+
spec : str
|
309
|
+
The format specification.
|
310
|
+
|
311
|
+
Returns
|
312
|
+
-------
|
313
|
+
text : str
|
314
|
+
The formatted string.
|
192
315
|
"""
|
193
316
|
if self.chisquare is None:
|
194
|
-
return
|
317
|
+
return "N/A"
|
195
318
|
if spec.endswith(Format.LATEX):
|
196
|
-
return f
|
319
|
+
return f"$\\chi^2$ = {self.chisquare:.2f} / {self.dof} dof"
|
197
320
|
if spec.endswith(Format.PRETTY):
|
198
|
-
return f
|
199
|
-
return f
|
321
|
+
return f"χ² = {self.chisquare:.2f} / {self.dof} dof"
|
322
|
+
return f"chisquare = {self.chisquare:.2f} / {self.dof} dof"
|
200
323
|
|
201
324
|
def __str__(self) -> str:
|
202
325
|
"""String formatting.
|
326
|
+
|
327
|
+
Returns
|
328
|
+
-------
|
329
|
+
text : str
|
330
|
+
The formatted string.
|
203
331
|
"""
|
204
332
|
return format(self, Format.PRETTY)
|
205
333
|
|
206
334
|
|
207
|
-
class
|
335
|
+
class AbstractFitModelBase(ABC):
|
208
336
|
|
209
|
-
"""Abstract base class for
|
337
|
+
"""Abstract base class for all the fit classes.
|
338
|
+
|
339
|
+
This is a acting a base class for both simple fit models and for composite models
|
340
|
+
(e.g., sums of simple ones).
|
210
341
|
"""
|
211
342
|
|
212
343
|
def __init__(self) -> None:
|
213
344
|
"""Constructor.
|
214
|
-
|
215
|
-
Here we loop over the FitParameter objects defined at the class level, and
|
216
|
-
create copies that are attached to the instance, so that the latter has its
|
217
|
-
own state.
|
218
345
|
"""
|
219
|
-
self._parameters = []
|
220
|
-
for name, value in self.__class__.__dict__.items():
|
221
|
-
if isinstance(value, FitParameter):
|
222
|
-
parameter = value.copy(name)
|
223
|
-
# Note we also set one instance attribute for each parameter so
|
224
|
-
# that we can use the notation model.parameter
|
225
|
-
setattr(self, name, parameter)
|
226
|
-
self._parameters.append(parameter)
|
227
|
-
# Fit status object holding all the additional information from the fit.
|
228
346
|
self.status = FitStatus()
|
229
347
|
|
230
|
-
|
231
|
-
"""Return the model name.
|
232
|
-
"""
|
233
|
-
return self.__class__.__name__
|
234
|
-
|
348
|
+
@abstractmethod
|
235
349
|
def __len__(self) -> int:
|
236
|
-
"""
|
237
|
-
|
238
|
-
return len(self._parameters)
|
350
|
+
"""Delegated to concrete classes: this should return the `total` number of
|
351
|
+
fit parameters (not only the free ones) in the model.
|
239
352
|
|
240
|
-
|
241
|
-
"""Iteration protocol.
|
242
|
-
"""
|
243
|
-
return iter(self._parameters)
|
353
|
+
.. note::
|
244
354
|
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
355
|
+
I still have mixed feelings about this method, as it is not clear whether
|
356
|
+
we are returning the number of parameters, or the number of free parameters,
|
357
|
+
but I think it is fine, as long as we document it. Also note that, while
|
358
|
+
the number of parameters is fixed once and for all for simple models,
|
359
|
+
it can change at runtime for composite models.
|
249
360
|
|
250
|
-
|
251
|
-
|
361
|
+
Returns
|
362
|
+
-------
|
363
|
+
n : int
|
364
|
+
The total number of fit parameters in the model.
|
252
365
|
"""
|
253
|
-
return tuple(parameter for parameter in self if not parameter.frozen)
|
254
366
|
|
255
|
-
|
256
|
-
|
367
|
+
@abstractmethod
|
368
|
+
def __iter__(self) -> Iterator[FitParameter]:
|
369
|
+
"""Delegated to concrete classes: this should return an iterator over `all`
|
370
|
+
the fit parameters in the model.
|
371
|
+
|
372
|
+
Returns
|
373
|
+
-------
|
374
|
+
iterator : Iterator[FitParameter]
|
375
|
+
An iterator over all the fit parameters in the model.
|
257
376
|
"""
|
258
|
-
return tuple(parameter.value for parameter in self.free_parameters())
|
259
377
|
|
260
378
|
@staticmethod
|
261
379
|
@abstractmethod
|
262
|
-
def evaluate(x: ArrayLike, *parameter_values:
|
263
|
-
"""Evaluate the model at a given
|
264
|
-
for a given set of model parameters.
|
380
|
+
def evaluate(x: ArrayLike, *parameter_values: Sequence[float]) -> ArrayLike:
|
381
|
+
"""Evaluate the model at a given set of parameter values.
|
265
382
|
|
266
383
|
Arguments
|
267
384
|
---------
|
268
385
|
x : array_like
|
269
386
|
The value(s) of the independent variable.
|
270
387
|
|
271
|
-
|
388
|
+
parameter_values : sequence of float
|
272
389
|
The value of the model parameters.
|
390
|
+
|
391
|
+
Returns
|
392
|
+
-------
|
393
|
+
y : array_like
|
394
|
+
The value(s) of the model at the given value(s) of the independent variable
|
395
|
+
for a given set of parameter values.
|
273
396
|
"""
|
274
397
|
|
398
|
+
def name(self) -> str:
|
399
|
+
"""Return the model name, e.g., for legends.
|
400
|
+
|
401
|
+
Note this can be reimplemented in concrete subclasses, but it should provide
|
402
|
+
a sensible default value in most circumstances.
|
403
|
+
|
404
|
+
Returns
|
405
|
+
-------
|
406
|
+
name : str
|
407
|
+
The model name.
|
408
|
+
"""
|
409
|
+
return self.__class__.__name__
|
410
|
+
|
275
411
|
def __call__(self, x: ArrayLike) -> ArrayLike:
|
276
412
|
"""Evaluate the model at the current value of the parameters.
|
413
|
+
|
414
|
+
Arguments
|
415
|
+
---------
|
416
|
+
x : array_like
|
417
|
+
The value(s) of the independent variable.
|
418
|
+
|
419
|
+
Returns
|
420
|
+
-------
|
421
|
+
y : array_like
|
422
|
+
The value(s) of the model at the given value(s) of the independent variable
|
423
|
+
for the current set of parameter values.
|
277
424
|
"""
|
278
425
|
return self.evaluate(x, *self.parameter_values())
|
279
426
|
|
280
|
-
def
|
281
|
-
"""
|
282
|
-
|
283
|
-
|
284
|
-
|
427
|
+
def init_parameters(self, xdata: ArrayLike, ydata: ArrayLike, sigma: ArrayLike) -> None:
|
428
|
+
"""Optional hook to change the current parameter values of the model, prior
|
429
|
+
to a fit, based on the input data.
|
430
|
+
|
431
|
+
Arguments
|
432
|
+
---------
|
433
|
+
xdata : array_like
|
434
|
+
The input values of the independent variable.
|
435
|
+
|
436
|
+
ydata : array_like
|
437
|
+
The input values of the dependent variable.
|
285
438
|
|
286
|
-
|
287
|
-
|
439
|
+
sigma : array_like
|
440
|
+
The input uncertainties on the dependent variable.
|
288
441
|
"""
|
289
442
|
# pylint: disable=unused-argument
|
290
443
|
return
|
291
444
|
|
292
|
-
def
|
293
|
-
"""
|
445
|
+
def parameter_values(self) -> Tuple[float]:
|
446
|
+
"""Return the current parameter values.
|
447
|
+
|
448
|
+
Note this only relies on the __iter__() method, so it works both for simple
|
449
|
+
and composite models.
|
450
|
+
|
451
|
+
Returns
|
452
|
+
-------
|
453
|
+
values : tuple of float
|
454
|
+
The current parameter values.
|
294
455
|
"""
|
295
|
-
|
296
|
-
|
297
|
-
|
456
|
+
return tuple(parameter.value for parameter in self)
|
457
|
+
|
458
|
+
def free_parameters(self) -> Tuple[FitParameter]:
|
459
|
+
"""Return the list of free parameters.
|
460
|
+
|
461
|
+
Note this only relies on the __iter__() method, so it works both for simple
|
462
|
+
and composite models.
|
463
|
+
|
464
|
+
Returns
|
465
|
+
-------
|
466
|
+
parameters : tuple of FitParameter
|
467
|
+
The list of free parameters.
|
468
|
+
"""
|
469
|
+
return tuple(parameter for parameter in self if not parameter.frozen)
|
470
|
+
|
471
|
+
def free_parameter_values(self) -> Tuple[float]:
|
472
|
+
"""Return the current parameter values.
|
473
|
+
|
474
|
+
Returns
|
475
|
+
-------
|
476
|
+
values : tuple of float
|
477
|
+
The current parameter values.
|
478
|
+
"""
|
479
|
+
return tuple(parameter.value for parameter in self.free_parameters())
|
298
480
|
|
299
481
|
def bounds(self) -> Tuple[ArrayLike, ArrayLike]:
|
300
482
|
"""Return the bounds on the fit parameters in a form that can be use by the
|
301
483
|
fitting method.
|
484
|
+
|
485
|
+
Returns
|
486
|
+
-------
|
487
|
+
bounds : 2-tuple of array_like
|
488
|
+
The lower and upper bounds on the (free) fit parameters.
|
302
489
|
"""
|
303
490
|
free_parameters = self.free_parameters()
|
304
491
|
return (tuple(parameter.minimum for parameter in free_parameters),
|
305
492
|
tuple(parameter.maximum for parameter in free_parameters))
|
306
493
|
|
307
|
-
def
|
494
|
+
def update_parameters(self, popt: np.ndarray, pcov: np.ndarray) -> None:
|
495
|
+
"""Update the model parameters based on the output of the ``curve_fit()`` call.
|
496
|
+
|
497
|
+
Arguments
|
498
|
+
---------
|
499
|
+
popt : array_like
|
500
|
+
The optimal values for the fit parameters.
|
501
|
+
|
502
|
+
pcov : array_like
|
503
|
+
The covariance matrix for the fit parameters.
|
504
|
+
"""
|
505
|
+
for parameter, value, error in zip(self.free_parameters(), popt, np.sqrt(pcov.diagonal())):
|
506
|
+
parameter.value = value
|
507
|
+
parameter.error = error
|
508
|
+
|
509
|
+
def calculate_chisquare(self, xdata: np.ndarray, ydata: np.ndarray, sigma) -> float:
|
308
510
|
"""Calculate the chisquare of the fit to some input data with the current
|
309
511
|
model parameters.
|
512
|
+
|
513
|
+
Arguments
|
514
|
+
---------
|
515
|
+
xdata : array_like
|
516
|
+
The input values of the independent variable.
|
517
|
+
|
518
|
+
ydata : array_like
|
519
|
+
The input values of the dependent variable.
|
520
|
+
|
521
|
+
sigma : array_like
|
522
|
+
The input uncertainties on the dependent variable.
|
523
|
+
|
524
|
+
Returns
|
525
|
+
-------
|
526
|
+
chisquare : float
|
527
|
+
The chisquare of the fit.
|
310
528
|
"""
|
311
529
|
return float((((ydata - self(xdata)) / sigma)**2.).sum())
|
312
530
|
|
313
531
|
@staticmethod
|
314
|
-
def freeze(model_function, **constraints):
|
532
|
+
def freeze(model_function, **constraints) -> Callable:
|
315
533
|
"""Freeze a subset of the model parameters.
|
534
|
+
|
535
|
+
Arguments
|
536
|
+
---------
|
537
|
+
model_function : callable
|
538
|
+
The model function to freeze parameters for.
|
539
|
+
|
540
|
+
constraints : dict
|
541
|
+
The parameters to freeze, as keyword arguments.
|
542
|
+
|
543
|
+
Returns
|
544
|
+
-------
|
545
|
+
wrapper : callable
|
546
|
+
A wrapper around the model function with the given parameters frozen.
|
316
547
|
"""
|
317
548
|
if not constraints:
|
318
549
|
return model_function
|
@@ -337,7 +568,7 @@ class AbstractFitModel(ABC):
|
|
337
568
|
# print out an error message.
|
338
569
|
unknown_parameter_names = set(constraints) - set(parameter_names)
|
339
570
|
if unknown_parameter_names:
|
340
|
-
raise ValueError(f
|
571
|
+
raise ValueError(f"Cannot freeze unknown parameters {unknown_parameter_names}")
|
341
572
|
|
342
573
|
# Now we need to build the signature for the new function, starting from a
|
343
574
|
# clean copy of the parameter for the independent variable...
|
@@ -353,8 +584,8 @@ class AbstractFitModel(ABC):
|
|
353
584
|
@functools.wraps(model_function)
|
354
585
|
def wrapper(x, *args):
|
355
586
|
if len(args) != num_free_parameters:
|
356
|
-
raise TypeError(f
|
357
|
-
f
|
587
|
+
raise TypeError(f"Frozen wrapper got {len(args)} parameters instead of " \
|
588
|
+
f"{num_free_parameters} ({free_parameter_names})")
|
358
589
|
parameter_dict = {**dict(zip(free_parameter_names, args)), **constraints}
|
359
590
|
return model_function(x, *[parameter_dict[name] for name in parameter_names])
|
360
591
|
|
@@ -363,8 +594,36 @@ class AbstractFitModel(ABC):
|
|
363
594
|
|
364
595
|
def fit(self, xdata: ArrayLike, ydata: ArrayLike, p0: ArrayLike = None,
|
365
596
|
sigma: ArrayLike = 1., absolute_sigma: bool = False, xmin: float = -np.inf,
|
366
|
-
xmax: float = np.inf, **kwargs) ->
|
597
|
+
xmax: float = np.inf, **kwargs) -> FitStatus:
|
367
598
|
"""Fit a series of points.
|
599
|
+
|
600
|
+
Arguments
|
601
|
+
---------
|
602
|
+
xdata : array_like
|
603
|
+
The input values of the independent variable.
|
604
|
+
|
605
|
+
ydata : array_like
|
606
|
+
The input values of the dependent variable.
|
607
|
+
|
608
|
+
p0 : array_like, optional
|
609
|
+
The initial values for the fit parameters.
|
610
|
+
|
611
|
+
sigma : array_like
|
612
|
+
The input uncertainties on the dependent variable.
|
613
|
+
|
614
|
+
absolute_sigma : bool, optional (default False)
|
615
|
+
See the `curve_fit()` documentation for details.
|
616
|
+
|
617
|
+
xmin : float, optional (default -inf)
|
618
|
+
The minimum value of the independent variable to fit.
|
619
|
+
|
620
|
+
xmax : float, optional (default inf)
|
621
|
+
The maximum value of the independent variable to fit.
|
622
|
+
|
623
|
+
Returns
|
624
|
+
-------
|
625
|
+
status : FitStatus
|
626
|
+
The status of the fit.
|
368
627
|
"""
|
369
628
|
# Reset the fit status.
|
370
629
|
self.status.reset()
|
@@ -374,16 +633,21 @@ class AbstractFitModel(ABC):
|
|
374
633
|
# the broadcast facilities.
|
375
634
|
xdata = np.asarray(xdata)
|
376
635
|
ydata = np.asarray(ydata)
|
636
|
+
if isinstance(sigma, Number):
|
637
|
+
sigma = np.full(ydata.shape, sigma)
|
638
|
+
sigma = np.asarray(sigma)
|
377
639
|
# If we are fitting over a subrange, filter the input data.
|
378
640
|
mask = np.logical_and(xdata >= xmin, xdata <= xmax)
|
641
|
+
# Also, filter out any points with non-positive uncertainties.
|
642
|
+
mask = np.logical_and(mask, sigma > 0.)
|
379
643
|
# (And, since we are at it, make sure we have enough degrees of freedom.)
|
380
|
-
self.status.dof = int(mask.sum() - len(self))
|
644
|
+
self.status.dof = int(mask.sum() - len(self.free_parameters()))
|
381
645
|
if self.status.dof < 0:
|
382
|
-
raise RuntimeError(f
|
646
|
+
raise RuntimeError(f"{self.name()} has no degrees of freedom")
|
383
647
|
xdata = xdata[mask]
|
384
648
|
ydata = ydata[mask]
|
385
|
-
|
386
|
-
|
649
|
+
sigma = sigma[mask]
|
650
|
+
|
387
651
|
# Cache the fit range for later use.
|
388
652
|
self.status.fit_range = (xdata.min(), xdata.max())
|
389
653
|
|
@@ -399,16 +663,38 @@ class AbstractFitModel(ABC):
|
|
399
663
|
model = self.freeze(self.evaluate, **constraints)
|
400
664
|
args = model, xdata, ydata, p0, sigma, absolute_sigma, True, self.bounds()
|
401
665
|
popt, pcov = curve_fit(*args, **kwargs)
|
402
|
-
self.
|
403
|
-
self.status.
|
666
|
+
self.update_parameters(popt, pcov)
|
667
|
+
self.status.update(self.calculate_chisquare(xdata, ydata, sigma))
|
404
668
|
return self.status
|
405
669
|
|
670
|
+
def fit_histogram(self, histogram: Histogram1d, p0: ArrayLike = None, **kwargs) -> None:
|
671
|
+
"""Convenience function for fitting a 1-dimensional histogram.
|
672
|
+
|
673
|
+
Arguments
|
674
|
+
---------
|
675
|
+
histogram : Histogram1d
|
676
|
+
The histogram to fit.
|
677
|
+
|
678
|
+
p0 : array_like, optional
|
679
|
+
The initial values for the fit parameters.
|
680
|
+
|
681
|
+
kwargs : dict, optional
|
682
|
+
Additional keyword arguments passed to `fit()`.
|
683
|
+
"""
|
684
|
+
args = histogram.bin_centers(), histogram.content, p0, histogram.errors
|
685
|
+
return self.fit(*args, **kwargs)
|
686
|
+
|
406
687
|
def default_plotting_range(self) -> Tuple[float, float]:
|
407
688
|
"""Return the default plotting range for the model.
|
408
689
|
|
409
|
-
This can be
|
690
|
+
This can be reimplemented in concrete models, and can be parameter-dependent
|
410
691
|
(e.g., for a gaussian we might want to plot within 5 sigma from the mean by
|
411
|
-
|
692
|
+
default).
|
693
|
+
|
694
|
+
Returns
|
695
|
+
-------
|
696
|
+
Tuple[float, float]
|
697
|
+
The default plotting range for the model.
|
412
698
|
"""
|
413
699
|
return (0., 1.)
|
414
700
|
|
@@ -416,6 +702,22 @@ class AbstractFitModel(ABC):
|
|
416
702
|
fit_padding: float = 0.) -> Tuple[float, float]:
|
417
703
|
"""Convenience function trying to come up with the most sensible plot range
|
418
704
|
for the model.
|
705
|
+
|
706
|
+
Arguments
|
707
|
+
---------
|
708
|
+
xmin : float, optional
|
709
|
+
The minimum value of the independent variable to plot.
|
710
|
+
|
711
|
+
xmax : float, optional
|
712
|
+
The maximum value of the independent variable to plot.
|
713
|
+
|
714
|
+
fit_padding : float, optional
|
715
|
+
The amount of padding to add to the fit range.
|
716
|
+
|
717
|
+
Returns
|
718
|
+
-------
|
719
|
+
Tuple[float, float]
|
720
|
+
The plotting range for the model.
|
419
721
|
"""
|
420
722
|
# If we have fitted the model to some data, we take the fit range and pad it
|
421
723
|
# a little bit.
|
@@ -434,27 +736,191 @@ class AbstractFitModel(ABC):
|
|
434
736
|
_xmax = xmax
|
435
737
|
return (_xmin, _xmax)
|
436
738
|
|
437
|
-
def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) ->
|
739
|
+
def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> np.ndarray:
|
438
740
|
"""Plot the model.
|
741
|
+
|
742
|
+
Arguments
|
743
|
+
---------
|
744
|
+
xmin : float, optional
|
745
|
+
The minimum value of the independent variable to plot.
|
746
|
+
|
747
|
+
xmax : float, optional
|
748
|
+
The maximum value of the independent variable to plot.
|
749
|
+
|
750
|
+
num_points : int, optional
|
751
|
+
The number of points to use for the plot.
|
752
|
+
|
753
|
+
Returns
|
754
|
+
-------
|
755
|
+
x : np.ndarray
|
756
|
+
The x values used for the plot, that can be used downstream to add
|
757
|
+
artists on the plot itself (e.g., composite models can use the same
|
758
|
+
grid to draw the components).
|
439
759
|
"""
|
440
760
|
x = np.linspace(*self._plotting_range(xmin, xmax), num_points)
|
441
761
|
y = self(x)
|
442
762
|
plt.plot(x, y, label=format(self, Format.LATEX))
|
763
|
+
return x
|
443
764
|
|
444
765
|
def __format__(self, spec: str) -> str:
|
445
766
|
"""String formatting.
|
767
|
+
|
768
|
+
Arguments
|
769
|
+
---------
|
770
|
+
spec : str
|
771
|
+
The format specification.
|
772
|
+
|
773
|
+
Returns
|
774
|
+
-------
|
775
|
+
text : str
|
776
|
+
The formatted string.
|
446
777
|
"""
|
447
|
-
text = f
|
448
|
-
|
449
|
-
text = f
|
450
|
-
|
778
|
+
text = f"{self.name()}\n"
|
779
|
+
if self.status is not None:
|
780
|
+
text = f"{text}{format(self.status, spec)}\n"
|
781
|
+
for parameter in self:
|
782
|
+
text = f"{text}{format(parameter, spec)}\n"
|
783
|
+
return text.strip("\n")
|
451
784
|
|
452
785
|
def __str__(self):
|
453
786
|
"""String formatting.
|
787
|
+
|
788
|
+
Returns
|
789
|
+
-------
|
790
|
+
text : str
|
791
|
+
The formatted string.
|
454
792
|
"""
|
455
793
|
return format(self, Format.PRETTY)
|
456
794
|
|
457
795
|
|
796
|
+
class AbstractFitModel(AbstractFitModelBase):
|
797
|
+
|
798
|
+
"""Abstract base class for a fit model.
|
799
|
+
"""
|
800
|
+
|
801
|
+
def __init__(self) -> None:
|
802
|
+
"""Constructor.
|
803
|
+
|
804
|
+
Here we loop over the FitParameter objects defined at the class level, and
|
805
|
+
create copies that are attached to the instance, so that the latter has its
|
806
|
+
own state.
|
807
|
+
"""
|
808
|
+
super().__init__()
|
809
|
+
self._parameters = []
|
810
|
+
for name, value in self.__class__.__dict__.items():
|
811
|
+
if isinstance(value, FitParameter):
|
812
|
+
parameter = value.copy(name)
|
813
|
+
# Note we also set one instance attribute for each parameter so
|
814
|
+
# that we can use the notation model.parameter
|
815
|
+
setattr(self, name, parameter)
|
816
|
+
self._parameters.append(parameter)
|
817
|
+
|
818
|
+
def __len__(self) -> int:
|
819
|
+
"""Return the `total` number of fit parameters in the model.
|
820
|
+
"""
|
821
|
+
return len(self._parameters)
|
822
|
+
|
823
|
+
def __iter__(self) -> Iterator[FitParameter]:
|
824
|
+
"""Iterate over `all` the model parameters.
|
825
|
+
"""
|
826
|
+
return iter(self._parameters)
|
827
|
+
|
828
|
+
def __add__(self, other):
|
829
|
+
"""Model sum.
|
830
|
+
"""
|
831
|
+
if not isinstance(other, AbstractFitModel):
|
832
|
+
raise TypeError(f"{other} is not a fit model")
|
833
|
+
return FitModelSum(self, other)
|
834
|
+
|
835
|
+
|
836
|
+
class FitModelSum(AbstractFitModelBase):
|
837
|
+
|
838
|
+
"""Composite model representing the sum of an arbitrary number of simple models.
|
839
|
+
|
840
|
+
Arguments
|
841
|
+
---------
|
842
|
+
components : sequence of AbstractFitModel
|
843
|
+
The components of the composite model.
|
844
|
+
"""
|
845
|
+
|
846
|
+
def __init__(self, *components: AbstractFitModel) -> None:
|
847
|
+
"""Constructor.
|
848
|
+
"""
|
849
|
+
super().__init__()
|
850
|
+
self._components = components
|
851
|
+
|
852
|
+
def name(self) -> str:
|
853
|
+
"""Return the model name.
|
854
|
+
"""
|
855
|
+
return " + ".join(component.name() for component in self._components)
|
856
|
+
|
857
|
+
def __len__(self) -> int:
|
858
|
+
"""Return the sum of `all` the fit parameters in the underlying models.
|
859
|
+
"""
|
860
|
+
return sum(len(component) for component in self._components)
|
861
|
+
|
862
|
+
def __iter__(self) -> Iterator[FitParameter]:
|
863
|
+
"""Iterate over `all` the parameters of the underlying components.
|
864
|
+
"""
|
865
|
+
return chain(*self._components)
|
866
|
+
|
867
|
+
def evaluate(self, x: ArrayLike, *parameter_values) -> ArrayLike:
|
868
|
+
"""Overloaded method for evaluating the model.
|
869
|
+
|
870
|
+
Note this is not a static method, as we need to access the list of components
|
871
|
+
to sum over.
|
872
|
+
"""
|
873
|
+
# pylint: disable=arguments-differ
|
874
|
+
cursor = 0
|
875
|
+
value = np.zeros(x.shape)
|
876
|
+
for component in self._components:
|
877
|
+
value += component.evaluate(x, *parameter_values[cursor:cursor + len(component)])
|
878
|
+
cursor += len(component)
|
879
|
+
return value
|
880
|
+
|
881
|
+
def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
|
882
|
+
"""Overloaded method for plotting the model.
|
883
|
+
"""
|
884
|
+
x = super().plot(xmin, xmax, num_points)
|
885
|
+
color = plt.gca().lines[-1].get_color()
|
886
|
+
for component in self._components:
|
887
|
+
y = component(x)
|
888
|
+
plt.plot(x, y, label=None, ls="--", color=color)
|
889
|
+
|
890
|
+
def __format__(self, spec: str) -> str:
|
891
|
+
"""String formatting.
|
892
|
+
|
893
|
+
Arguments
|
894
|
+
---------
|
895
|
+
spec : str
|
896
|
+
The format specification.
|
897
|
+
|
898
|
+
Returns
|
899
|
+
-------
|
900
|
+
text : str
|
901
|
+
The formatted string.
|
902
|
+
"""
|
903
|
+
text = f"{self.name()}\n"
|
904
|
+
if self.status is not None:
|
905
|
+
text = f"{text}{format(self.status, spec)}\n"
|
906
|
+
for component in self._components:
|
907
|
+
text = f"{text}[{component.name()}]\n"
|
908
|
+
for parameter in component:
|
909
|
+
text = f"{text}{format(parameter, spec)}\n"
|
910
|
+
return text.strip("\n")
|
911
|
+
|
912
|
+
def __add__(self, other: AbstractFitModel) -> "FitModelSum":
|
913
|
+
"""Implementation of the model sum (i.e., using the `+` operator).
|
914
|
+
|
915
|
+
Note that, in the spirit of keeping the interfaces as simple as possible,
|
916
|
+
we are not implementing in-place addition (i.e., `+=`), and we only
|
917
|
+
allow ``AbstractFitModel`` objects (not ``FitModelSum``) on the right
|
918
|
+
hand side, which is all is needed to support the sum of an arbitrary
|
919
|
+
number of models.
|
920
|
+
"""
|
921
|
+
return self.__class__(*self._components, other)
|
922
|
+
|
923
|
+
|
458
924
|
class Constant(AbstractFitModel):
|
459
925
|
|
460
926
|
"""Constant model.
|
@@ -465,7 +931,7 @@ class Constant(AbstractFitModel):
|
|
465
931
|
@staticmethod
|
466
932
|
def evaluate(x: ArrayLike, value: float) -> ArrayLike:
|
467
933
|
# pylint: disable=arguments-differ
|
468
|
-
return np.full(
|
934
|
+
return np.full(x.shape, value)
|
469
935
|
|
470
936
|
|
471
937
|
class Line(AbstractFitModel):
|
@@ -503,13 +969,27 @@ class Gaussian(AbstractFitModel):
|
|
503
969
|
|
504
970
|
prefactor = FitParameter(1.)
|
505
971
|
mean = FitParameter(0.)
|
506
|
-
sigma = FitParameter(1.)
|
972
|
+
sigma = FitParameter(1., minimum=0.)
|
973
|
+
|
974
|
+
_NORM_CONSTANT = 1. / np.sqrt(2. * np.pi)
|
975
|
+
_SIGMA_TO_FWHM = 2. * np.sqrt(2. * np.log(2.))
|
507
976
|
|
508
977
|
@staticmethod
|
509
978
|
def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
|
510
979
|
# pylint: disable=arguments-differ
|
511
|
-
|
980
|
+
z = (x - mean) / sigma
|
981
|
+
return prefactor * Gaussian._NORM_CONSTANT / sigma * np.exp(-0.5 * z**2.)
|
512
982
|
|
513
983
|
def default_plotting_range(self, num_sigma: int = 5) -> Tuple[float, float]:
|
514
984
|
mean, half_width = self.mean.value, num_sigma * self.sigma.value
|
515
985
|
return (mean - half_width, mean + half_width)
|
986
|
+
|
987
|
+
def fwhm(self) -> uncertainties.ufloat:
|
988
|
+
"""Return the full-width at half-maximum (FWHM) of the gaussian.
|
989
|
+
|
990
|
+
Returns
|
991
|
+
-------
|
992
|
+
fwhm : uncertainties.ufloat
|
993
|
+
The FWHM of the gaussian.
|
994
|
+
"""
|
995
|
+
return self.sigma.ufloat() * self._SIGMA_TO_FWHM
|