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 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"]