labfit 0.1.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.
- labfit-0.1.0/PKG-INFO +87 -0
- labfit-0.1.0/README.md +64 -0
- labfit-0.1.0/labfit/__init__.py +22 -0
- labfit-0.1.0/labfit/api.py +14 -0
- labfit-0.1.0/labfit/fit.py +40 -0
- labfit-0.1.0/labfit/fitter_impl.py +416 -0
- labfit-0.1.0/labfit/io.py +240 -0
- labfit-0.1.0/labfit/models.py +441 -0
- labfit-0.1.0/labfit/plot.py +499 -0
- labfit-0.1.0/labfit/types.py +334 -0
- labfit-0.1.0/labfit/utils.py +65 -0
- labfit-0.1.0/labfit.egg-info/PKG-INFO +87 -0
- labfit-0.1.0/labfit.egg-info/SOURCES.txt +23 -0
- labfit-0.1.0/labfit.egg-info/dependency_links.txt +1 -0
- labfit-0.1.0/labfit.egg-info/requires.txt +3 -0
- labfit-0.1.0/labfit.egg-info/top_level.txt +1 -0
- labfit-0.1.0/pyproject.toml +47 -0
- labfit-0.1.0/setup.cfg +4 -0
- labfit-0.1.0/tests/test_fit_curve.py +18 -0
- labfit-0.1.0/tests/test_io.py +39 -0
- labfit-0.1.0/tests/test_labfit.py +94 -0
- labfit-0.1.0/tests/test_labfit_branches.py +381 -0
- labfit-0.1.0/tests/test_non_convergence.py +214 -0
- labfit-0.1.0/tests/test_plot_module.py +70 -0
- labfit-0.1.0/tests/test_roundtrip_ci.py +181 -0
labfit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: labfit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Student-friendly least-squares curve fitting for Python
|
|
5
|
+
Author-email: Matthew Doyle <matt@matthewd0yle.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://matthewd0yle.com
|
|
8
|
+
Project-URL: Documentation, https://matthewd0yle.com/labfit
|
|
9
|
+
Project-URL: Source, https://github.com/matthewjdoyle/labfit
|
|
10
|
+
Keywords: curve-fitting,least-squares,data-analysis,physics,chi-squared
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Education
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: numpy>=1.26
|
|
21
|
+
Requires-Dist: scipy>=1.11
|
|
22
|
+
Requires-Dist: matplotlib>=3.8
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="https://raw.githubusercontent.com/matthewjdoyle/labfit/master/docs/_static/banner.svg" alt="LabFit - least-squares curve fitting for Python" width="100%">
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<a href="https://matthewd0yle.com/labfit/"><strong>Documentation</strong></a> ·
|
|
30
|
+
<a href="#quick-start"><strong>Quick start</strong></a> ·
|
|
31
|
+
<a href="https://matthewd0yle.com/labfit/gallery.html"><strong>Gallery</strong></a>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
**LabFit** is a small, student-friendly Python library for least-squares curve fitting,
|
|
35
|
+
error propagation, and publication-quality plotting - built on NumPy, SciPy, and Matplotlib.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
pip install labfit
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from labfit import fit, plot_fit
|
|
45
|
+
|
|
46
|
+
result = fit("data.csv", model="exponential")
|
|
47
|
+
plot_fit(result)
|
|
48
|
+
print(f"χ²/ν = {result.reduced_chi2:.3f}")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Three lines: CSV → fit → plot → goodness-of-fit.
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- **Built-in models** - linear, quadratic, Gaussian, Lorentzian, exponential, power law,
|
|
56
|
+
logistic, sinc, bimodal Gaussian, damped oscillator, and more - all by name.
|
|
57
|
+
- **Reduced χ²** on every fit result, so you can immediately assess goodness-of-fit.
|
|
58
|
+
- **Asymmetric & correlated errors** - handle `sigma_low`/`sigma_high` and full
|
|
59
|
+
covariance matrices without rewriting propagation code.
|
|
60
|
+
- **Multi-series fitting** - fit and compare many data sets on shared or independent axes.
|
|
61
|
+
- **Plots** - residual panels, multi-fit grids, proper axis labels.
|
|
62
|
+
- **100 % Python** - no compiled extensions, installs everywhere.
|
|
63
|
+
|
|
64
|
+
## Longer example
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import numpy as np
|
|
68
|
+
from labfit import quick_fit, plot_fit
|
|
69
|
+
|
|
70
|
+
rng = np.random.default_rng(42)
|
|
71
|
+
x = np.linspace(-5, 5, 200)
|
|
72
|
+
y = 3.0 * np.exp(-0.5 * ((x - 0.5) / 1.2) ** 2) + rng.normal(0, 0.05, size=x.size)
|
|
73
|
+
|
|
74
|
+
result = quick_fit(x, y, model="gaussian")
|
|
75
|
+
print(f"amplitude = {result.params['amplitude']:.3f}")
|
|
76
|
+
print(f"mean = {result.params['mean']:.3f}")
|
|
77
|
+
print(f"sigma = {result.params['sigma']:.3f}")
|
|
78
|
+
print(f"χ²/ν = {result.reduced_chi2:.4f}")
|
|
79
|
+
|
|
80
|
+
plot = plot_fit(result, show_residuals=True)
|
|
81
|
+
plot.save("gaussian_fit.png")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Dependencies
|
|
85
|
+
|
|
86
|
+
- Python ≥ 3.10
|
|
87
|
+
- NumPy, SciPy, Matplotlib
|
labfit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/matthewjdoyle/labfit/master/docs/_static/banner.svg" alt="LabFit - least-squares curve fitting for Python" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://matthewd0yle.com/labfit/"><strong>Documentation</strong></a> ·
|
|
7
|
+
<a href="#quick-start"><strong>Quick start</strong></a> ·
|
|
8
|
+
<a href="https://matthewd0yle.com/labfit/gallery.html"><strong>Gallery</strong></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
**LabFit** is a small, student-friendly Python library for least-squares curve fitting,
|
|
12
|
+
error propagation, and publication-quality plotting - built on NumPy, SciPy, and Matplotlib.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
pip install labfit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from labfit import fit, plot_fit
|
|
22
|
+
|
|
23
|
+
result = fit("data.csv", model="exponential")
|
|
24
|
+
plot_fit(result)
|
|
25
|
+
print(f"χ²/ν = {result.reduced_chi2:.3f}")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Three lines: CSV → fit → plot → goodness-of-fit.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Built-in models** - linear, quadratic, Gaussian, Lorentzian, exponential, power law,
|
|
33
|
+
logistic, sinc, bimodal Gaussian, damped oscillator, and more - all by name.
|
|
34
|
+
- **Reduced χ²** on every fit result, so you can immediately assess goodness-of-fit.
|
|
35
|
+
- **Asymmetric & correlated errors** - handle `sigma_low`/`sigma_high` and full
|
|
36
|
+
covariance matrices without rewriting propagation code.
|
|
37
|
+
- **Multi-series fitting** - fit and compare many data sets on shared or independent axes.
|
|
38
|
+
- **Plots** - residual panels, multi-fit grids, proper axis labels.
|
|
39
|
+
- **100 % Python** - no compiled extensions, installs everywhere.
|
|
40
|
+
|
|
41
|
+
## Longer example
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import numpy as np
|
|
45
|
+
from labfit import quick_fit, plot_fit
|
|
46
|
+
|
|
47
|
+
rng = np.random.default_rng(42)
|
|
48
|
+
x = np.linspace(-5, 5, 200)
|
|
49
|
+
y = 3.0 * np.exp(-0.5 * ((x - 0.5) / 1.2) ** 2) + rng.normal(0, 0.05, size=x.size)
|
|
50
|
+
|
|
51
|
+
result = quick_fit(x, y, model="gaussian")
|
|
52
|
+
print(f"amplitude = {result.params['amplitude']:.3f}")
|
|
53
|
+
print(f"mean = {result.params['mean']:.3f}")
|
|
54
|
+
print(f"sigma = {result.params['sigma']:.3f}")
|
|
55
|
+
print(f"χ²/ν = {result.reduced_chi2:.4f}")
|
|
56
|
+
|
|
57
|
+
plot = plot_fit(result, show_residuals=True)
|
|
58
|
+
plot.save("gaussian_fit.png")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Dependencies
|
|
62
|
+
|
|
63
|
+
- Python ≥ 3.10
|
|
64
|
+
- NumPy, SciPy, Matplotlib
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .api import fit, fit_multi, fit_to_model, quick_fit, plot_fit, plot_multi_fit, plot_residuals
|
|
2
|
+
from .fit import fit_curve
|
|
3
|
+
from .types import AsymmetricError, DataSeries, Dataset, Fitter, FitResult, Plotter, Series
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"fit",
|
|
7
|
+
"fit_curve",
|
|
8
|
+
"fit_multi",
|
|
9
|
+
"fit_to_model",
|
|
10
|
+
"quick_fit",
|
|
11
|
+
"plot_fit",
|
|
12
|
+
"plot_multi_fit",
|
|
13
|
+
"plot_residuals",
|
|
14
|
+
] + [
|
|
15
|
+
"AsymmetricError",
|
|
16
|
+
"DataSeries",
|
|
17
|
+
"Dataset",
|
|
18
|
+
"Series",
|
|
19
|
+
"Fitter",
|
|
20
|
+
"FitResult",
|
|
21
|
+
"Plotter",
|
|
22
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .fit import fit_curve
|
|
2
|
+
from .fitter_impl import fit, fit_multi, fit_to_model, quick_fit
|
|
3
|
+
from .plot import plot_fit, plot_multi_fit, plot_residuals
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"fit",
|
|
7
|
+
"fit_curve",
|
|
8
|
+
"fit_multi",
|
|
9
|
+
"fit_to_model",
|
|
10
|
+
"quick_fit",
|
|
11
|
+
"plot_fit",
|
|
12
|
+
"plot_multi_fit",
|
|
13
|
+
"plot_residuals",
|
|
14
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .fitter_impl import fit as _fit
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fit_curve(model, x, y, y_err, *, p0=None, bounds=None, label="", **kwargs):
|
|
7
|
+
"""Fit a model to data with explicit 1-sigma y uncertainties.
|
|
8
|
+
|
|
9
|
+
A convenience wrapper around :func:`fit` that keeps the error
|
|
10
|
+
specification as a dedicated positional argument for clarity.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
model : str or callable
|
|
15
|
+
Built-in model name or custom callable.
|
|
16
|
+
x : array-like
|
|
17
|
+
x-values.
|
|
18
|
+
y : array-like
|
|
19
|
+
y-values.
|
|
20
|
+
y_err : array-like
|
|
21
|
+
1-sigma uncertainties for each y-value.
|
|
22
|
+
p0 : dict or array-like, optional
|
|
23
|
+
Initial parameter guesses.
|
|
24
|
+
bounds : dict or tuple of arrays, optional
|
|
25
|
+
Parameter bounds.
|
|
26
|
+
label : str, optional
|
|
27
|
+
Label for legends.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
FitResult
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
return _fit(x, y, model=model, sigma=y_err, p0=p0, bounds=bounds, label=label, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
fit = fit_curve
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["fit_curve", "fit"]
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from scipy.optimize import least_squares
|
|
10
|
+
from scipy.stats import chi2 as chi2_dist
|
|
11
|
+
|
|
12
|
+
from .models import get_model, model_param_names
|
|
13
|
+
from .types import DataSeries, Dataset, FitResult
|
|
14
|
+
from .utils import effective_sigma, load_csv
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _coerce_series_input(x, y=None, *, sigma=None, sigma_low=None, sigma_high=None, sigma_cov=None, label=""):
|
|
18
|
+
if isinstance(x, DataSeries) and y is None:
|
|
19
|
+
return x
|
|
20
|
+
if isinstance(x, (str, Path)) and y is None:
|
|
21
|
+
x_arr, y_arr, sig, sig_low, sig_high = load_csv(x)
|
|
22
|
+
return DataSeries(
|
|
23
|
+
x=x_arr,
|
|
24
|
+
y=y_arr,
|
|
25
|
+
sigma=sigma if sigma is not None else sig,
|
|
26
|
+
sigma_low=sigma_low if sigma_low is not None else sig_low,
|
|
27
|
+
sigma_high=sigma_high if sigma_high is not None else sig_high,
|
|
28
|
+
sigma_cov=sigma_cov,
|
|
29
|
+
label=label,
|
|
30
|
+
)
|
|
31
|
+
if y is None:
|
|
32
|
+
raise TypeError("fit requires either (x, y) arrays or a DataSeries / CSV path")
|
|
33
|
+
return DataSeries(x=x, y=y, sigma=sigma, sigma_low=sigma_low, sigma_high=sigma_high, sigma_cov=sigma_cov, label=label)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _initial_guess(model, name, x, y, p0=None):
|
|
37
|
+
param_names = model_param_names(model)
|
|
38
|
+
if p0 is not None:
|
|
39
|
+
if isinstance(p0, dict):
|
|
40
|
+
return [float(p0.get(p, 1.0)) for p in param_names]
|
|
41
|
+
arr = np.asarray(p0, dtype=float)
|
|
42
|
+
if arr.ndim == 0:
|
|
43
|
+
return [float(arr)]
|
|
44
|
+
return arr.astype(float).tolist()
|
|
45
|
+
|
|
46
|
+
x = np.asarray(x, dtype=float)
|
|
47
|
+
y = np.asarray(y, dtype=float)
|
|
48
|
+
span = float(np.ptp(x)) if x.size else 1.0
|
|
49
|
+
yrange = float(np.ptp(y)) if y.size else 1.0
|
|
50
|
+
y_min = float(np.min(y)) if y.size else 0.0
|
|
51
|
+
y_max = float(np.max(y)) if y.size else 1.0
|
|
52
|
+
y_mean = float(np.mean(y)) if y.size else 0.0
|
|
53
|
+
x_mean = float(np.mean(x)) if x.size else 0.0
|
|
54
|
+
x_at_max = float(x[int(np.argmax(y))]) if y.size else 0.0
|
|
55
|
+
|
|
56
|
+
if name == "linear":
|
|
57
|
+
if x.size >= 2:
|
|
58
|
+
slope, intercept = np.polyfit(x, y, 1)
|
|
59
|
+
return [float(slope), float(intercept)]
|
|
60
|
+
return [1.0, y_mean]
|
|
61
|
+
if name == "quadratic":
|
|
62
|
+
coeffs = np.polyfit(x, y, 2) if x.size >= 3 else [0.0, 1.0, y_mean]
|
|
63
|
+
return [float(v) for v in coeffs]
|
|
64
|
+
if name == "cubic":
|
|
65
|
+
coeffs = np.polyfit(x, y, 3) if x.size >= 4 else [0.0, 0.0, 1.0, y_mean]
|
|
66
|
+
return [float(v) for v in coeffs]
|
|
67
|
+
if name == "constant":
|
|
68
|
+
return [y_mean]
|
|
69
|
+
if name == "gaussian":
|
|
70
|
+
sigma0 = span / 6.0 if span else 1.0
|
|
71
|
+
amp = y_max - y_min if yrange else 1.0
|
|
72
|
+
return [amp, x_at_max, max(sigma0, 1e-6)]
|
|
73
|
+
if name == "lorentzian":
|
|
74
|
+
amp = y_max - y_min if yrange else 1.0
|
|
75
|
+
return [amp, x_at_max, max(span / 10.0, 1e-6)]
|
|
76
|
+
if name == "exponential":
|
|
77
|
+
amp = y[0] if y.size else 1.0
|
|
78
|
+
decay = 1.0 / max(span, 1.0)
|
|
79
|
+
positive = y > 0
|
|
80
|
+
if positive.sum() >= 2 and x.size >= 2:
|
|
81
|
+
slope, intercept = np.polyfit(x[positive], np.log(y[positive]), 1)
|
|
82
|
+
amp = float(np.exp(intercept))
|
|
83
|
+
decay = float(-slope)
|
|
84
|
+
return [float(amp), float(decay)]
|
|
85
|
+
if name == "power_law":
|
|
86
|
+
amp = max(y_max, 1e-6)
|
|
87
|
+
exponent = 1.0
|
|
88
|
+
positive = (x > 0) & (y > 0)
|
|
89
|
+
if positive.sum() >= 2:
|
|
90
|
+
slope, intercept = np.polyfit(np.log(x[positive]), np.log(y[positive]), 1)
|
|
91
|
+
amp = float(np.exp(intercept))
|
|
92
|
+
exponent = float(slope)
|
|
93
|
+
return [float(amp), float(exponent)]
|
|
94
|
+
if name == "logistic":
|
|
95
|
+
return [yrange or 1.0, x_mean, 1.0 / max(span, 1.0), y_min]
|
|
96
|
+
if name in {"sine", "cosine", "damped_sine", "damped_oscillator", "beat"}:
|
|
97
|
+
freq = 1.0 / max(span, 1.0)
|
|
98
|
+
amp = max(abs(y_min), abs(y_max), 1.0)
|
|
99
|
+
if x.size > 1:
|
|
100
|
+
dx = np.diff(x)
|
|
101
|
+
if np.all(dx > 0):
|
|
102
|
+
y_detrended = y - y_mean
|
|
103
|
+
zero_crossings = np.where(np.signbit(y_detrended[:-1]) != np.signbit(y_detrended[1:]))[0]
|
|
104
|
+
if zero_crossings.size >= 2:
|
|
105
|
+
est_periods = np.diff(x[zero_crossings]) * 2.0
|
|
106
|
+
period = float(np.median(est_periods)) if est_periods.size else span
|
|
107
|
+
if period > 0:
|
|
108
|
+
freq = 1.0 / period
|
|
109
|
+
if name == "damped_oscillator":
|
|
110
|
+
return [float(amp), 0.1 / max(span, 1.0), float(freq), 0.0]
|
|
111
|
+
if name == "damped_sine":
|
|
112
|
+
return [float(amp), 0.1 / max(span, 1.0), float(freq), 0.0, float(y_mean)]
|
|
113
|
+
if name == "sine":
|
|
114
|
+
return [float(amp), float(freq), 0.0, float(y_mean)]
|
|
115
|
+
if name == "cosine":
|
|
116
|
+
return [float(amp), float(freq), 0.0, float(y_mean)]
|
|
117
|
+
if name == "beat":
|
|
118
|
+
return [float(amp), float(freq), float(freq * 1.08), 0.0, float(y_mean)]
|
|
119
|
+
if name == "voigt":
|
|
120
|
+
amp = y_max - y_min if yrange else 1.0
|
|
121
|
+
return [amp, x_at_max, max(span / 6.0, 1e-6), max(span / 10.0, 1e-6)]
|
|
122
|
+
if name == "skew_normal":
|
|
123
|
+
amp = y_max - y_min if yrange else 1.0
|
|
124
|
+
return [amp, x_at_max, max(span / 6.0, 1e-6), 0.0]
|
|
125
|
+
if name in {"gaussian_fwhm", "lorentzian_fwhm"}:
|
|
126
|
+
amp = y_max - y_min if yrange else 1.0
|
|
127
|
+
sigma0 = span / 6.0 if span else 1.0
|
|
128
|
+
fwhm0 = sigma0 / 0.42466
|
|
129
|
+
return [amp, x_at_max, max(fwhm0, 1e-6)]
|
|
130
|
+
if name == "exgaussian":
|
|
131
|
+
amp = y_max - y_min if yrange else 1.0
|
|
132
|
+
return [amp, x_at_max, max(span / 8.0, 1e-6), max(span / 4.0, 1e-6)]
|
|
133
|
+
if name == "stretched_exponential":
|
|
134
|
+
amp = y[0] if y.size else 1.0
|
|
135
|
+
return [float(amp), max(span, 1.0), 1.0, float(y_min)]
|
|
136
|
+
if name in {"tanh", "arctan"}:
|
|
137
|
+
return [yrange or 1.0, x_mean, max(span / 4.0, 1e-6), float(y_mean)]
|
|
138
|
+
if name == "rational":
|
|
139
|
+
return [1.0, x_mean]
|
|
140
|
+
if name == "quartic":
|
|
141
|
+
coeffs = np.polyfit(x, y, 4) if x.size >= 5 else [0.0, 0.0, 0.0, 1.0, y_mean]
|
|
142
|
+
return [float(v) for v in coeffs]
|
|
143
|
+
if name == "quintic":
|
|
144
|
+
coeffs = np.polyfit(x, y, 5) if x.size >= 6 else [0.0, 0.0, 0.0, 0.0, 1.0, y_mean]
|
|
145
|
+
return [float(v) for v in coeffs]
|
|
146
|
+
return [1.0] * len(param_names)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _coerce_bounds(bounds, param_names):
|
|
150
|
+
if bounds is None:
|
|
151
|
+
n = len(param_names)
|
|
152
|
+
return (np.full(n, -np.inf, dtype=float), np.full(n, np.inf, dtype=float))
|
|
153
|
+
if isinstance(bounds, dict):
|
|
154
|
+
lower = []
|
|
155
|
+
upper = []
|
|
156
|
+
for name in param_names:
|
|
157
|
+
lo, hi = bounds.get(name, (-np.inf, np.inf))
|
|
158
|
+
lower.append(-np.inf if lo is None else lo)
|
|
159
|
+
upper.append(np.inf if hi is None else hi)
|
|
160
|
+
return np.asarray(lower, dtype=float), np.asarray(upper, dtype=float)
|
|
161
|
+
if isinstance(bounds, (tuple, list)) and len(bounds) == 2:
|
|
162
|
+
lo, hi = bounds
|
|
163
|
+
return np.asarray(lo, dtype=float), np.asarray(hi, dtype=float)
|
|
164
|
+
raise TypeError("bounds must be None, a (lower, upper) pair, or a dict keyed by parameter name")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _normalize_sigma(series: DataSeries, sigma=None, weights=None, ysigma=None, sigma_low=None, sigma_high=None, sigma_cov=None):
|
|
168
|
+
if ysigma is not None and sigma is None:
|
|
169
|
+
sigma = ysigma
|
|
170
|
+
if sigma is None and series.effective_sigma is not None:
|
|
171
|
+
sigma = series.effective_sigma
|
|
172
|
+
if sigma_low is None and series.sigma_low is not None:
|
|
173
|
+
sigma_low = series.sigma_low
|
|
174
|
+
if sigma_high is None and series.sigma_high is not None:
|
|
175
|
+
sigma_high = series.sigma_high
|
|
176
|
+
if sigma_cov is None and series.sigma_cov is not None:
|
|
177
|
+
sigma_cov = series.sigma_cov
|
|
178
|
+
sigma = effective_sigma(sigma=sigma, sigma_low=sigma_low, sigma_high=sigma_high, sigma_cov=sigma_cov)
|
|
179
|
+
if weights is not None:
|
|
180
|
+
if sigma is not None:
|
|
181
|
+
raise ValueError("Pass either sigma/ysigma or weights, not both")
|
|
182
|
+
weights = np.asarray(weights, dtype=float)
|
|
183
|
+
sigma = np.where(weights > 0, 1.0 / np.sqrt(weights), np.inf)
|
|
184
|
+
if sigma_cov is not None:
|
|
185
|
+
sigma_cov = np.asarray(sigma_cov, dtype=float)
|
|
186
|
+
return sigma, sigma_cov
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _model_wrapper(model, param_names):
|
|
190
|
+
fn, model_name = get_model(model)
|
|
191
|
+
if callable(model) and not isinstance(model, str):
|
|
192
|
+
model_name = getattr(model, "__name__", "custom")
|
|
193
|
+
return fn, model_name, param_names or model_param_names(fn)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _fit_single(series: DataSeries, *, model="linear", p0=None, bounds=None, sigma=None, weights=None, ysigma=None, sigma_low=None, sigma_high=None, sigma_cov=None) -> FitResult:
|
|
197
|
+
fn, model_name, param_names = _model_wrapper(model, model_param_names(model))
|
|
198
|
+
x = np.asarray(series.x, dtype=float)
|
|
199
|
+
y = np.asarray(series.y, dtype=float)
|
|
200
|
+
sigma, sigma_cov = _normalize_sigma(series, sigma=sigma, weights=weights, ysigma=ysigma, sigma_low=sigma_low, sigma_high=sigma_high, sigma_cov=sigma_cov)
|
|
201
|
+
p0_vec = np.asarray(_initial_guess(model, model_name, x, y, p0=p0), dtype=float)
|
|
202
|
+
if p0_vec.size != len(param_names):
|
|
203
|
+
p0_vec = np.resize(p0_vec, len(param_names)).astype(float)
|
|
204
|
+
lb, ub = _coerce_bounds(bounds, param_names)
|
|
205
|
+
|
|
206
|
+
if sigma_cov is not None:
|
|
207
|
+
sigma_cov = np.asarray(sigma_cov, dtype=float)
|
|
208
|
+
chol = np.linalg.cholesky(sigma_cov)
|
|
209
|
+
|
|
210
|
+
def residuals(params):
|
|
211
|
+
r = y - fn(x, *params)
|
|
212
|
+
return np.linalg.solve(chol, r)
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
sigma_vec = None if sigma is None else np.asarray(sigma, dtype=float)
|
|
216
|
+
|
|
217
|
+
def residuals(params):
|
|
218
|
+
r = y - fn(x, *params)
|
|
219
|
+
if sigma_vec is None:
|
|
220
|
+
return r
|
|
221
|
+
return r / sigma_vec
|
|
222
|
+
|
|
223
|
+
opt = least_squares(residuals, p0_vec, bounds=(lb, ub), method="trf")
|
|
224
|
+
popt = opt.x
|
|
225
|
+
y_fit = np.asarray(fn(x, *popt), dtype=float)
|
|
226
|
+
residual = y - y_fit
|
|
227
|
+
dof = max(int(x.size - popt.size), 1)
|
|
228
|
+
if sigma_cov is not None:
|
|
229
|
+
chi2 = float(np.sum(np.square(np.linalg.solve(chol, residual))))
|
|
230
|
+
elif sigma is not None:
|
|
231
|
+
sigma_vec = np.asarray(sigma, dtype=float)
|
|
232
|
+
chi2 = float(np.sum(np.square(residual / sigma_vec)))
|
|
233
|
+
else:
|
|
234
|
+
chi2 = float(np.sum(np.square(residual)))
|
|
235
|
+
reduced_chi2 = chi2 / dof
|
|
236
|
+
|
|
237
|
+
if opt.jac is not None and opt.jac.size:
|
|
238
|
+
jtj = opt.jac.T @ opt.jac
|
|
239
|
+
cov = np.linalg.pinv(jtj) * reduced_chi2
|
|
240
|
+
else:
|
|
241
|
+
cov = np.full((popt.size, popt.size), np.nan)
|
|
242
|
+
|
|
243
|
+
params = {name: float(value) for name, value in zip(param_names, popt)}
|
|
244
|
+
uncertainties = {
|
|
245
|
+
name: float(np.sqrt(max(float(value), 0.0)))
|
|
246
|
+
for name, value in zip(param_names, np.diag(cov))
|
|
247
|
+
}
|
|
248
|
+
p_value = float(chi2_dist.sf(chi2, dof)) if dof > 0 and np.isfinite(chi2) else float("nan")
|
|
249
|
+
|
|
250
|
+
# ── actionable warnings for students ─────────────────────
|
|
251
|
+
if not opt.success:
|
|
252
|
+
warnings.warn(
|
|
253
|
+
f"Fit '{model_name}' did NOT converge. "
|
|
254
|
+
f"Optimiser message: {opt.message}. "
|
|
255
|
+
f"Try providing better initial guesses (p0), adding parameter bounds, "
|
|
256
|
+
f"or checking whether '{model_name}' is the right model.",
|
|
257
|
+
UserWarning,
|
|
258
|
+
stacklevel=3,
|
|
259
|
+
)
|
|
260
|
+
elif sigma is not None and reduced_chi2 > 10:
|
|
261
|
+
warnings.warn(
|
|
262
|
+
f"reduced chi2 = {reduced_chi2:.1f} is much larger than 1. "
|
|
263
|
+
f"The model '{model_name}' may not describe the data well, "
|
|
264
|
+
f"or the measurement uncertainties may be underestimated. "
|
|
265
|
+
f"Inspect the residuals and check your model choice.",
|
|
266
|
+
UserWarning,
|
|
267
|
+
stacklevel=3,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return FitResult(
|
|
271
|
+
reduced_chi2=float(reduced_chi2),
|
|
272
|
+
params=params,
|
|
273
|
+
covariance=cov,
|
|
274
|
+
p_value=p_value,
|
|
275
|
+
uncertainties=uncertainties,
|
|
276
|
+
success=bool(opt.success),
|
|
277
|
+
message=str(opt.message),
|
|
278
|
+
model_name=model_name,
|
|
279
|
+
param_names=tuple(param_names),
|
|
280
|
+
x=x,
|
|
281
|
+
y=y,
|
|
282
|
+
sigma=None if sigma is None else np.asarray(sigma, dtype=float),
|
|
283
|
+
y_fit=y_fit,
|
|
284
|
+
series=series,
|
|
285
|
+
model=fn,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def fit(x, y=None, *, model="linear", p0=None, bounds=None, sigma=None, weights=None, ysigma=None, sigma_low=None, sigma_high=None, sigma_cov=None, label="", **kwargs):
|
|
290
|
+
"""Fit a model to data and return parameter estimates with uncertainties.
|
|
291
|
+
|
|
292
|
+
This is the main entry point. The first argument can be arrays,
|
|
293
|
+
a :class:`~labfit.DataSeries`, or a path to a CSV file — the
|
|
294
|
+
function adapts automatically.
|
|
295
|
+
|
|
296
|
+
Parameters
|
|
297
|
+
----------
|
|
298
|
+
x : array-like or DataSeries or Path or str
|
|
299
|
+
x-values for the data. If a :class:`~labfit.DataSeries` or a CSV
|
|
300
|
+
path is passed, ``y`` and the error columns are read from it.
|
|
301
|
+
y : array-like, optional
|
|
302
|
+
y-values (ignored if ``x`` is a DataSeries or CSV path).
|
|
303
|
+
model : str or callable, default ``"linear"``
|
|
304
|
+
Built-in model name (``"gaussian"``, ``"exponential"``, …) or a
|
|
305
|
+
custom callable ``f(x, *params)``.
|
|
306
|
+
p0 : dict or array-like, optional
|
|
307
|
+
Initial parameter guesses. Dict keys must match the parameter
|
|
308
|
+
names (e.g. ``{"amplitude": 1.0, "mean": 0.0}``).
|
|
309
|
+
bounds : dict or tuple of arrays, optional
|
|
310
|
+
Parameter bounds. Either a dict ``{"name": (lo, hi)}`` or a
|
|
311
|
+
``(lower, upper)`` tuple of arrays.
|
|
312
|
+
sigma : array-like or AsymmetricError, optional
|
|
313
|
+
1-σ uncertainties for each y-value.
|
|
314
|
+
weights : array-like, optional
|
|
315
|
+
Inverse-variance weights (alternative to ``sigma``).
|
|
316
|
+
sigma_low, sigma_high : array-like, optional
|
|
317
|
+
Asymmetric lower and upper uncertainties.
|
|
318
|
+
sigma_cov : array-like, optional
|
|
319
|
+
Full covariance matrix for correlated measurement errors.
|
|
320
|
+
label : str, optional
|
|
321
|
+
Label for the data series (used in plot legends).
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
FitResult
|
|
326
|
+
Container with ``params``, ``uncertainties``, ``reduced_chi2``,
|
|
327
|
+
``p_value``, and convenience methods like ``predict(x)`` and
|
|
328
|
+
``residuals``.
|
|
329
|
+
"""
|
|
330
|
+
if kwargs:
|
|
331
|
+
# Preserve forwards compatibility for optional callers while keeping the core API explicit.
|
|
332
|
+
if "ysigma" in kwargs and ysigma is None:
|
|
333
|
+
ysigma = kwargs.pop("ysigma")
|
|
334
|
+
if "sigma" in kwargs and sigma is None:
|
|
335
|
+
sigma = kwargs.pop("sigma")
|
|
336
|
+
if kwargs:
|
|
337
|
+
unexpected = ", ".join(sorted(kwargs))
|
|
338
|
+
raise TypeError(f"Unexpected keyword arguments: {unexpected}")
|
|
339
|
+
|
|
340
|
+
series = _coerce_series_input(x, y, sigma=sigma, sigma_low=sigma_low, sigma_high=sigma_high, sigma_cov=sigma_cov, label=label)
|
|
341
|
+
return _fit_single(series, model=model, p0=p0, bounds=bounds, sigma=sigma, weights=weights, ysigma=ysigma, sigma_low=sigma_low, sigma_high=sigma_high, sigma_cov=sigma_cov)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def quick_fit(x, y=None, **kwargs):
|
|
345
|
+
"""Fit a model with automatic initial guesses.
|
|
346
|
+
|
|
347
|
+
Convenience alias for :func:`fit`. All keyword arguments
|
|
348
|
+
(``model``, ``sigma``, ``p0``, ``bounds``, …) are forwarded.
|
|
349
|
+
|
|
350
|
+
Parameters
|
|
351
|
+
----------
|
|
352
|
+
x : array-like or DataSeries or Path or str
|
|
353
|
+
x-values, a DataSeries, or a CSV path.
|
|
354
|
+
y : array-like, optional
|
|
355
|
+
y-values.
|
|
356
|
+
|
|
357
|
+
Returns
|
|
358
|
+
-------
|
|
359
|
+
FitResult
|
|
360
|
+
"""
|
|
361
|
+
return fit(x, y, **kwargs)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def fit_to_model(x, y=None, **kwargs):
|
|
365
|
+
"""Fit a model, emphasising the model choice at the call site.
|
|
366
|
+
|
|
367
|
+
Alias for :func:`fit` that reads naturally when you already know
|
|
368
|
+
which model you want to use::
|
|
369
|
+
|
|
370
|
+
result = fit_to_model("data.csv", model="exponential")
|
|
371
|
+
|
|
372
|
+
Parameters
|
|
373
|
+
----------
|
|
374
|
+
x : array-like or DataSeries or Path or str
|
|
375
|
+
x-values, a DataSeries, or a CSV path.
|
|
376
|
+
y : array-like, optional
|
|
377
|
+
y-values.
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
FitResult
|
|
382
|
+
"""
|
|
383
|
+
return fit(x, y, **kwargs)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def fit_multi(dataset: Iterable[DataSeries] | Dataset, *, model="linear", p0=None, bounds=None, **kwargs):
|
|
387
|
+
"""Fit the same model to every series in a collection.
|
|
388
|
+
|
|
389
|
+
Parameters
|
|
390
|
+
----------
|
|
391
|
+
dataset : Dataset or iterable of DataSeries
|
|
392
|
+
The data series to fit. Each series can carry its own label
|
|
393
|
+
and error specification.
|
|
394
|
+
model : str or callable, default ``"linear"``
|
|
395
|
+
Model name or custom callable.
|
|
396
|
+
p0 : dict or array-like, optional
|
|
397
|
+
Shared initial parameter guesses for all series.
|
|
398
|
+
bounds : dict or tuple of arrays, optional
|
|
399
|
+
Shared parameter bounds for all series.
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
list of FitResult
|
|
404
|
+
One result per input series.
|
|
405
|
+
"""
|
|
406
|
+
if isinstance(dataset, Dataset):
|
|
407
|
+
series_list = list(dataset)
|
|
408
|
+
else:
|
|
409
|
+
series_list = list(dataset)
|
|
410
|
+
return [
|
|
411
|
+
_fit_single(series, model=model, p0=p0, bounds=bounds, **kwargs)
|
|
412
|
+
for series in series_list
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
__all__ = ["fit", "quick_fit", "fit_to_model", "fit_multi"]
|