fitwise 0.1.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.
- fitwise/__init__.py +30 -0
- fitwise/_typing.py +11 -0
- fitwise/_validate.py +110 -0
- fitwise/api.py +230 -0
- fitwise/backends/__init__.py +6 -0
- fitwise/backends/base.py +103 -0
- fitwise/backends/least_squares.py +145 -0
- fitwise/model.py +450 -0
- fitwise/models/__init__.py +21 -0
- fitwise/models/_heuristics.py +105 -0
- fitwise/models/basic.py +102 -0
- fitwise/models/curves.py +136 -0
- fitwise/models/peaks.py +105 -0
- fitwise/parameters.py +115 -0
- fitwise/plotting.py +103 -0
- fitwise/py.typed +0 -0
- fitwise/report.py +154 -0
- fitwise/result.py +361 -0
- fitwise/serialize.py +138 -0
- fitwise/warnings.py +48 -0
- fitwise-0.1.0.dist-info/METADATA +104 -0
- fitwise-0.1.0.dist-info/RECORD +24 -0
- fitwise-0.1.0.dist-info/WHEEL +4 -0
- fitwise-0.1.0.dist-info/licenses/LICENSE +28 -0
fitwise/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""fitwise: curve fitting made easy.
|
|
2
|
+
|
|
3
|
+
One call, one fit, one report you can trust.
|
|
4
|
+
|
|
5
|
+
>>> import fitwise as fw
|
|
6
|
+
>>> result = fw.fit(fw.models.gaussian, x, y, yerr=dy) # doctest: +SKIP
|
|
7
|
+
>>> print(result) # doctest: +SKIP
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from fitwise import models, warnings
|
|
11
|
+
from fitwise.api import fit
|
|
12
|
+
from fitwise.model import CompositeModel, Model
|
|
13
|
+
from fitwise.parameters import Parameter, Parameters
|
|
14
|
+
from fitwise.result import FitResult
|
|
15
|
+
from fitwise.warnings import FitWarning
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CompositeModel",
|
|
21
|
+
"FitResult",
|
|
22
|
+
"FitWarning",
|
|
23
|
+
"Model",
|
|
24
|
+
"Parameter",
|
|
25
|
+
"Parameters",
|
|
26
|
+
"__version__",
|
|
27
|
+
"fit",
|
|
28
|
+
"models",
|
|
29
|
+
"warnings",
|
|
30
|
+
]
|
fitwise/_typing.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared type aliases."""
|
|
2
|
+
|
|
3
|
+
from typing import TypeAlias
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike, NDArray
|
|
7
|
+
|
|
8
|
+
FloatArray: TypeAlias = NDArray[np.float64]
|
|
9
|
+
Bounds: TypeAlias = tuple[float, float]
|
|
10
|
+
|
|
11
|
+
__all__ = ["ArrayLike", "Bounds", "FloatArray"]
|
fitwise/_validate.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Input validation with error messages that tell the user what to do."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from fitwise._typing import ArrayLike, Bounds, FloatArray
|
|
14
|
+
|
|
15
|
+
NanPolicy = Literal["raise", "omit"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class FitData:
|
|
20
|
+
"""Validated data of one fit."""
|
|
21
|
+
|
|
22
|
+
x: FloatArray
|
|
23
|
+
y: FloatArray
|
|
24
|
+
yerr: FloatArray | None
|
|
25
|
+
n_omitted: int = 0
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def n(self) -> int:
|
|
29
|
+
return len(self.y)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def weights(self) -> FloatArray:
|
|
33
|
+
"""Residual weights ``1/yerr`` (ones without ``yerr``)."""
|
|
34
|
+
return np.ones_like(self.y) if self.yerr is None else 1.0 / self.yerr
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_data(
|
|
38
|
+
x: ArrayLike, y: ArrayLike, yerr: ArrayLike | None, nan_policy: NanPolicy
|
|
39
|
+
) -> FitData:
|
|
40
|
+
"""Convert to float arrays, check shapes and handle non-finite values."""
|
|
41
|
+
if nan_policy not in ("raise", "omit"):
|
|
42
|
+
raise ValueError(f"nan_policy must be 'raise' or 'omit', not {nan_policy!r}")
|
|
43
|
+
xa = np.asarray(x, dtype=float)
|
|
44
|
+
ya = np.asarray(y, dtype=float)
|
|
45
|
+
if ya.ndim != 1:
|
|
46
|
+
raise ValueError(f"y must be one-dimensional, got shape {ya.shape}")
|
|
47
|
+
if xa.ndim == 0 or xa.shape[0] != ya.shape[0]:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"x and y must have the same length along the first axis, "
|
|
50
|
+
f"got x.shape={xa.shape} and y.shape={ya.shape}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
ea: FloatArray | None = None
|
|
54
|
+
if yerr is not None:
|
|
55
|
+
ea = np.broadcast_to(np.asarray(yerr, dtype=float), ya.shape).copy()
|
|
56
|
+
|
|
57
|
+
finite = np.isfinite(ya) & np.isfinite(xa.reshape(len(ya), -1)).all(axis=1)
|
|
58
|
+
if ea is not None:
|
|
59
|
+
finite &= np.isfinite(ea)
|
|
60
|
+
n_bad = int((~finite).sum())
|
|
61
|
+
if n_bad:
|
|
62
|
+
if nan_policy == "raise":
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"{n_bad} data point(s) contain NaN or inf in x, y or yerr. "
|
|
65
|
+
"Clean the data or pass nan_policy='omit' to drop them."
|
|
66
|
+
)
|
|
67
|
+
xa, ya = xa[finite], ya[finite]
|
|
68
|
+
ea = None if ea is None else ea[finite]
|
|
69
|
+
|
|
70
|
+
if ea is not None and np.any(ea <= 0):
|
|
71
|
+
raise ValueError("yerr must be strictly positive; zero uncertainty gives infinite weight")
|
|
72
|
+
return FitData(xa, ya, ea, n_bad)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_names(argument: str, given: Iterable[str], known: Iterable[str]) -> None:
|
|
76
|
+
"""Raise a helpful error if ``given`` contains names not in ``known``."""
|
|
77
|
+
known = list(known)
|
|
78
|
+
for name in given:
|
|
79
|
+
if name not in known:
|
|
80
|
+
hint = difflib.get_close_matches(name, known, n=1)
|
|
81
|
+
suggestion = f" Did you mean {hint[0]!r}?" if hint else ""
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Unknown parameter {name!r} in {argument}.{suggestion} "
|
|
84
|
+
f"Model parameters: {', '.join(known)}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def as_float(argument: str, name: str, value: Any) -> float:
|
|
89
|
+
"""Convert a user-supplied scalar to float with a clear error."""
|
|
90
|
+
try:
|
|
91
|
+
f = float(value)
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
raise TypeError(f"{argument}[{name!r}] must be a number, got {value!r}") from None
|
|
94
|
+
return f
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_bounds(bounds: Mapping[str, Any] | None) -> dict[str, Bounds]:
|
|
98
|
+
"""Turn ``{name: (lo, hi)}`` with ``None`` for open sides into float pairs."""
|
|
99
|
+
result: dict[str, Bounds] = {}
|
|
100
|
+
for name, pair in (bounds or {}).items():
|
|
101
|
+
try:
|
|
102
|
+
lo, hi = pair
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
raise TypeError(f"bounds[{name!r}] must be a pair (lo, hi), got {pair!r}") from None
|
|
105
|
+
lo_f = -math.inf if lo is None else as_float("bounds", name, lo)
|
|
106
|
+
hi_f = math.inf if hi is None else as_float("bounds", name, hi)
|
|
107
|
+
if math.isnan(lo_f) or math.isnan(hi_f) or not lo_f < hi_f:
|
|
108
|
+
raise ValueError(f"bounds[{name!r}] = {pair!r}: need lo < hi")
|
|
109
|
+
result[name] = (lo_f, hi_f)
|
|
110
|
+
return result
|
fitwise/api.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""The public entry point :func:`fit`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from fitwise._typing import ArrayLike, Bounds
|
|
13
|
+
from fitwise._validate import (
|
|
14
|
+
FitData,
|
|
15
|
+
NanPolicy,
|
|
16
|
+
as_float,
|
|
17
|
+
check_names,
|
|
18
|
+
normalize_bounds,
|
|
19
|
+
validate_data,
|
|
20
|
+
)
|
|
21
|
+
from fitwise.backends import Backend, FitProblem, LeastSquaresBackend
|
|
22
|
+
from fitwise.model import Model, as_model
|
|
23
|
+
from fitwise.result import FitResult, build_result
|
|
24
|
+
|
|
25
|
+
__all__ = ["fit"]
|
|
26
|
+
|
|
27
|
+
Method = Literal["auto", "trf", "lm", "linear", "odr", "multistart"]
|
|
28
|
+
|
|
29
|
+
_PLANNED = {
|
|
30
|
+
"linear": "0.2",
|
|
31
|
+
"multistart": "0.2",
|
|
32
|
+
"odr": "0.3",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fit(
|
|
37
|
+
model: Model | Callable[..., ArrayLike] | str,
|
|
38
|
+
x: ArrayLike,
|
|
39
|
+
y: ArrayLike,
|
|
40
|
+
*,
|
|
41
|
+
yerr: ArrayLike | None = None,
|
|
42
|
+
xerr: ArrayLike | None = None,
|
|
43
|
+
p0: Mapping[str, float] | None = None,
|
|
44
|
+
bounds: Mapping[str, tuple[float | None, float | None]] | None = None,
|
|
45
|
+
fixed: Mapping[str, float] | None = None,
|
|
46
|
+
loss: str = "linear",
|
|
47
|
+
method: Method = "auto",
|
|
48
|
+
scale_covar: bool | None = None,
|
|
49
|
+
nan_policy: NanPolicy = "raise",
|
|
50
|
+
) -> FitResult:
|
|
51
|
+
"""Fit a model to data.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
model : Model or callable
|
|
56
|
+
A built-in model from :mod:`fitwise.models`, a composition such as
|
|
57
|
+
``gaussian + linear``, or a function ``f(x, p1, p2, ...)``.
|
|
58
|
+
x : array_like
|
|
59
|
+
Independent variable, shape ``(n,)`` or ``(n, k)``; passed to the model unchanged.
|
|
60
|
+
y : array_like
|
|
61
|
+
Measured values, shape ``(n,)``.
|
|
62
|
+
yerr : float or array_like, optional
|
|
63
|
+
Absolute one-sigma uncertainties of ``y``. Without ``yerr``, uncertainties are
|
|
64
|
+
estimated from the scatter of the residuals.
|
|
65
|
+
xerr : array_like, optional
|
|
66
|
+
Uncertainties of ``x`` (orthogonal distance regression; planned for 0.3).
|
|
67
|
+
p0 : dict, optional
|
|
68
|
+
Starting values ``{name: value}``; override any heuristic per parameter.
|
|
69
|
+
bounds : dict, optional
|
|
70
|
+
``{name: (lo, hi)}``; use ``None`` for an open side.
|
|
71
|
+
fixed : dict, optional
|
|
72
|
+
``{name: value}`` of parameters held constant.
|
|
73
|
+
loss : str
|
|
74
|
+
Only ``"linear"`` (ordinary least squares) is supported so far.
|
|
75
|
+
method : {"auto", "trf", "lm"}
|
|
76
|
+
Solver. ``"auto"`` currently selects Trust Region Reflective, which also handles
|
|
77
|
+
bounds; ``"lm"`` is Levenberg–Marquardt without bounds.
|
|
78
|
+
scale_covar : bool, optional
|
|
79
|
+
Multiply the covariance by χ²/ndf. Default: ``False`` with ``yerr`` (uncertainties are
|
|
80
|
+
taken as absolute), ``True`` without.
|
|
81
|
+
nan_policy : {"raise", "omit"}
|
|
82
|
+
What to do with non-finite data points.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
FitResult
|
|
87
|
+
Parameters with uncertainties, goodness of fit, warnings and a printable report.
|
|
88
|
+
|
|
89
|
+
Examples
|
|
90
|
+
--------
|
|
91
|
+
>>> import fitwise as fw
|
|
92
|
+
>>> x = np.linspace(-5, 5, 101)
|
|
93
|
+
>>> y = fw.models.gaussian(x, amp=2.0, cen=0.3, sigma=1.1)
|
|
94
|
+
>>> result = fw.fit(fw.models.gaussian, x, y + 0.01 * np.cos(7 * x))
|
|
95
|
+
>>> round(result.params["cen"].value, 2)
|
|
96
|
+
0.3
|
|
97
|
+
"""
|
|
98
|
+
m = as_model(model)
|
|
99
|
+
if xerr is not None:
|
|
100
|
+
raise NotImplementedError(
|
|
101
|
+
"xerr (orthogonal distance regression) is planned for fitwise 0.3"
|
|
102
|
+
)
|
|
103
|
+
if loss != "linear":
|
|
104
|
+
raise NotImplementedError("robust loss functions are planned for fitwise 0.2")
|
|
105
|
+
data = validate_data(x, y, yerr, nan_policy)
|
|
106
|
+
|
|
107
|
+
names = m.param_names
|
|
108
|
+
p0_given = dict(p0 or {})
|
|
109
|
+
fixed_given = dict(fixed or {})
|
|
110
|
+
bounds_given = normalize_bounds(bounds)
|
|
111
|
+
for argument, mapping in (("p0", p0_given), ("bounds", bounds_given), ("fixed", fixed_given)):
|
|
112
|
+
check_names(argument, mapping, names)
|
|
113
|
+
fixed_values = {n: as_float("fixed", n, v) for n, v in fixed_given.items()}
|
|
114
|
+
for n, v in fixed_values.items():
|
|
115
|
+
if not math.isfinite(v):
|
|
116
|
+
raise ValueError(f"fixed[{n!r}] must be finite, got {v}")
|
|
117
|
+
for argument, mapping in (("p0", p0_given), ("bounds", bounds_given)):
|
|
118
|
+
if n in mapping:
|
|
119
|
+
raise ValueError(f"parameter {n!r} is fixed and must not appear in {argument}")
|
|
120
|
+
|
|
121
|
+
free = [n for n in names if n not in fixed_values]
|
|
122
|
+
if not free:
|
|
123
|
+
raise ValueError("all parameters are fixed; there is nothing to fit")
|
|
124
|
+
if data.n < len(free):
|
|
125
|
+
raise ValueError(f"{data.n} data points cannot determine {len(free)} free parameters")
|
|
126
|
+
|
|
127
|
+
free_bounds = {n: bounds_given.get(n, (-math.inf, math.inf)) for n in free}
|
|
128
|
+
init, strategies = _initial_values(m, data, free, p0_given, free_bounds)
|
|
129
|
+
strategies.update(dict.fromkeys(fixed_values, "fixed"))
|
|
130
|
+
problem = FitProblem(m, data, init, fixed_values, free_bounds)
|
|
131
|
+
_check_start(problem, strategies)
|
|
132
|
+
|
|
133
|
+
backend, reason = _select_backend(method, problem)
|
|
134
|
+
solved = backend.solve(problem)
|
|
135
|
+
result = build_result(
|
|
136
|
+
problem,
|
|
137
|
+
solved,
|
|
138
|
+
strategies=strategies,
|
|
139
|
+
scale_covar=scale_covar,
|
|
140
|
+
method_reason=reason,
|
|
141
|
+
options={
|
|
142
|
+
"p0": p0_given,
|
|
143
|
+
"bounds": {n: list(b) for n, b in bounds_given.items()},
|
|
144
|
+
"fixed": fixed_values,
|
|
145
|
+
"method": method,
|
|
146
|
+
"scale_covar": scale_covar,
|
|
147
|
+
"nan_policy": nan_policy,
|
|
148
|
+
"loss": loss,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
for w in result.warnings:
|
|
152
|
+
warnings.warn(w, stacklevel=2)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _initial_values(
|
|
157
|
+
model: Model,
|
|
158
|
+
data: FitData,
|
|
159
|
+
free: list[str],
|
|
160
|
+
p0: Mapping[str, Any],
|
|
161
|
+
bounds: Mapping[str, Bounds],
|
|
162
|
+
) -> tuple[dict[str, float], dict[str, str]]:
|
|
163
|
+
"""Choose starting values by precedence: p0 > signature default > heuristic > fallback."""
|
|
164
|
+
defaults = model.defaults
|
|
165
|
+
needs_guess = any(n not in p0 and n not in defaults for n in free)
|
|
166
|
+
guessed = model._guess_labeled(data.x, data.y) if needs_guess else {}
|
|
167
|
+
|
|
168
|
+
init: dict[str, float] = {}
|
|
169
|
+
strategies: dict[str, str] = {}
|
|
170
|
+
for n in free:
|
|
171
|
+
lo, hi = bounds[n]
|
|
172
|
+
if n in p0:
|
|
173
|
+
value, strategy = as_float("p0", n, p0[n]), "p0"
|
|
174
|
+
if not lo <= value <= hi:
|
|
175
|
+
raise ValueError(f"p0[{n!r}] = {value:g} is outside its bounds ({lo:g}, {hi:g})")
|
|
176
|
+
elif n in defaults:
|
|
177
|
+
value, strategy = defaults[n], "signature default"
|
|
178
|
+
elif n in guessed:
|
|
179
|
+
value, strategy = guessed[n]
|
|
180
|
+
else:
|
|
181
|
+
value, strategy = _fallback(lo, hi), "fallback"
|
|
182
|
+
if not lo <= value <= hi:
|
|
183
|
+
value = float(np.clip(value, lo, hi))
|
|
184
|
+
strategy += " (clipped to bounds)"
|
|
185
|
+
init[n], strategies[n] = value, strategy
|
|
186
|
+
return init, strategies
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _fallback(lo: float, hi: float) -> float:
|
|
190
|
+
if math.isfinite(lo) and math.isfinite(hi):
|
|
191
|
+
return (lo + hi) / 2
|
|
192
|
+
if math.isfinite(lo):
|
|
193
|
+
return lo + max(1.0, abs(lo))
|
|
194
|
+
if math.isfinite(hi):
|
|
195
|
+
return hi - max(1.0, abs(hi))
|
|
196
|
+
return 1.0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_start(problem: FitProblem, strategies: Mapping[str, str]) -> None:
|
|
200
|
+
"""Fail early with a clear message if the model cannot be evaluated at the start."""
|
|
201
|
+
n = problem.data.n
|
|
202
|
+
start = problem.all_params(problem.init)
|
|
203
|
+
with np.errstate(all="ignore"):
|
|
204
|
+
model_y = problem.model.eval(problem.data.x, start)
|
|
205
|
+
try:
|
|
206
|
+
model_y = np.broadcast_to(model_y, (n,))
|
|
207
|
+
except ValueError:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"the model returned an array of shape {np.shape(model_y)}, expected ({n},)"
|
|
210
|
+
) from None
|
|
211
|
+
if not np.all(np.isfinite(model_y)):
|
|
212
|
+
values = ", ".join(f"{k} = {v:g} ({strategies[k]})" for k, v in start.items())
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"the model is not finite at the starting values {values}. "
|
|
215
|
+
"Pass better starting values with p0= or restrict parameters with bounds=."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _select_backend(method: str, problem: FitProblem) -> tuple[Backend, str]:
|
|
220
|
+
if method == "auto":
|
|
221
|
+
return LeastSquaresBackend("trf"), "auto"
|
|
222
|
+
if method == "trf":
|
|
223
|
+
return LeastSquaresBackend("trf"), "requested"
|
|
224
|
+
if method == "lm":
|
|
225
|
+
if problem.has_bounds:
|
|
226
|
+
raise ValueError("method='lm' does not support bounds; use 'trf' or 'auto'")
|
|
227
|
+
return LeastSquaresBackend("lm"), "requested"
|
|
228
|
+
if method in _PLANNED:
|
|
229
|
+
raise NotImplementedError(f"method={method!r} is planned for fitwise {_PLANNED[method]}")
|
|
230
|
+
raise ValueError(f"unknown method {method!r}; choose 'auto', 'trf' or 'lm'")
|
fitwise/backends/base.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The contract between :func:`fitwise.fit` and the solver backends.
|
|
2
|
+
|
|
3
|
+
Every backend receives a normalized :class:`FitProblem` and returns a :class:`BackendResult`.
|
|
4
|
+
:class:`fitwise.FitResult` is built only from these two objects, so all solvers produce
|
|
5
|
+
results with identical structure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from fitwise._typing import Bounds, FloatArray
|
|
16
|
+
from fitwise._validate import FitData
|
|
17
|
+
from fitwise.model import Model
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class FitProblem:
|
|
22
|
+
"""A fully specified least-squares problem.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
model, data
|
|
27
|
+
The model and the validated data.
|
|
28
|
+
init : dict
|
|
29
|
+
Starting values of the free parameters, in model order.
|
|
30
|
+
fixed : dict
|
|
31
|
+
Values of the fixed parameters.
|
|
32
|
+
bounds : dict
|
|
33
|
+
Bounds of the free parameters (``±inf`` if unbounded).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model: Model
|
|
37
|
+
data: FitData
|
|
38
|
+
init: dict[str, float]
|
|
39
|
+
fixed: dict[str, float]
|
|
40
|
+
bounds: dict[str, Bounds]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def free_names(self) -> tuple[str, ...]:
|
|
44
|
+
"""Names of the varied parameters in model order."""
|
|
45
|
+
return tuple(self.init)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def has_bounds(self) -> bool:
|
|
49
|
+
"""Whether any free parameter has a finite bound."""
|
|
50
|
+
return any(np.isfinite(b).any() for b in self.bounds.values())
|
|
51
|
+
|
|
52
|
+
def all_params(self, free: dict[str, float]) -> dict[str, float]:
|
|
53
|
+
"""Merge free and fixed values into a full parameter dict in model order."""
|
|
54
|
+
merged = {**free, **self.fixed}
|
|
55
|
+
return {n: merged[n] for n in self.model.param_names}
|
|
56
|
+
|
|
57
|
+
def weighted_residual(self, free: dict[str, float]) -> FloatArray:
|
|
58
|
+
"""``(y - f(x)) / yerr`` (or unweighted without ``yerr``)."""
|
|
59
|
+
model_y = self.model.eval(self.data.x, self.all_params(free))
|
|
60
|
+
return (self.data.y - np.broadcast_to(model_y, self.data.y.shape)) * self.data.weights
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class BackendResult:
|
|
65
|
+
"""What a backend reports back.
|
|
66
|
+
|
|
67
|
+
Attributes
|
|
68
|
+
----------
|
|
69
|
+
values : dict
|
|
70
|
+
Best-fit values of the free parameters.
|
|
71
|
+
cov_unscaled : ndarray or None
|
|
72
|
+
``(JᵀJ)⁻¹`` of the weighted residuals, in ``free_names`` order; not multiplied by
|
|
73
|
+
χ²/ndf. ``None`` if it could not be computed.
|
|
74
|
+
converged : bool
|
|
75
|
+
Whether the solver met its convergence criteria.
|
|
76
|
+
nfev : int
|
|
77
|
+
Number of model evaluations.
|
|
78
|
+
message : str
|
|
79
|
+
The solver's own status message.
|
|
80
|
+
method : str
|
|
81
|
+
Human-readable solver description for the report.
|
|
82
|
+
raw : object
|
|
83
|
+
The backend's native result object.
|
|
84
|
+
log : tuple of str
|
|
85
|
+
Notes about what the backend did, for ``FitResult.solver_log``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
values: dict[str, float]
|
|
89
|
+
cov_unscaled: FloatArray | None
|
|
90
|
+
converged: bool
|
|
91
|
+
nfev: int
|
|
92
|
+
message: str
|
|
93
|
+
method: str
|
|
94
|
+
raw: object = None
|
|
95
|
+
log: tuple[str, ...] = field(default_factory=tuple)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Backend(Protocol):
|
|
99
|
+
"""A solver that turns a :class:`FitProblem` into a :class:`BackendResult`."""
|
|
100
|
+
|
|
101
|
+
def solve(self, problem: FitProblem) -> BackendResult:
|
|
102
|
+
"""Solve the problem."""
|
|
103
|
+
...
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Nonlinear least squares via :func:`scipy.optimize.least_squares`.
|
|
2
|
+
|
|
3
|
+
Three measures make this robust on badly scaled problems (see ADR 5):
|
|
4
|
+
|
|
5
|
+
1. The solver works on normalized parameters ``u = p / s`` with ``s = |p_start|``, so that all
|
|
6
|
+
parameters are of order one.
|
|
7
|
+
2. The Jacobian uses central (3-point) finite differences. For Levenberg–Marquardt they are
|
|
8
|
+
computed here, because older scipy versions silently fall back to 2-point for ``lm``.
|
|
9
|
+
3. After convergence, the solver restarts from the solution with fresh normalization until the
|
|
10
|
+
cost no longer improves.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from functools import partial
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy.optimize import OptimizeResult, least_squares
|
|
22
|
+
|
|
23
|
+
from fitwise._typing import FloatArray
|
|
24
|
+
from fitwise.backends.base import BackendResult, FitProblem
|
|
25
|
+
|
|
26
|
+
_EPS_CBRT = float(np.finfo(float).eps ** (1 / 3))
|
|
27
|
+
|
|
28
|
+
_DESCRIPTIONS = {
|
|
29
|
+
"trf": "Trust Region Reflective (scipy least_squares)",
|
|
30
|
+
"lm": "Levenberg-Marquardt (MINPACK via scipy)",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LeastSquaresBackend:
|
|
36
|
+
"""Solve a :class:`FitProblem` with ``scipy.optimize.least_squares``.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
method : {"trf", "lm"}
|
|
41
|
+
Trust Region Reflective supports bounds; Levenberg–Marquardt does not.
|
|
42
|
+
tol : float
|
|
43
|
+
``xtol``, ``ftol`` and ``gtol`` of each solver run.
|
|
44
|
+
max_restarts : int
|
|
45
|
+
Maximum number of restarts from the current solution.
|
|
46
|
+
max_nfev : int or None
|
|
47
|
+
Evaluation limit per solver run; ``None`` uses scipy's default.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
method: Literal["trf", "lm"] = "trf"
|
|
51
|
+
tol: float = 1e-15
|
|
52
|
+
max_restarts: int = 10
|
|
53
|
+
max_nfev: int | None = None
|
|
54
|
+
|
|
55
|
+
def solve(self, problem: FitProblem) -> BackendResult:
|
|
56
|
+
"""Solve the problem, restarting until the cost stops improving."""
|
|
57
|
+
names = problem.free_names
|
|
58
|
+
if self.method == "lm" and problem.has_bounds:
|
|
59
|
+
raise ValueError("Levenberg-Marquardt does not support bounds; use method='trf'")
|
|
60
|
+
lo = np.array([problem.bounds[n][0] for n in names])
|
|
61
|
+
hi = np.array([problem.bounds[n][1] for n in names])
|
|
62
|
+
p = np.array([problem.init[n] for n in names])
|
|
63
|
+
|
|
64
|
+
log: list[str] = []
|
|
65
|
+
best_cost = np.inf
|
|
66
|
+
result: OptimizeResult | None = None
|
|
67
|
+
best_scale = _scale(p)
|
|
68
|
+
nfev = 0
|
|
69
|
+
for attempt in range(self.max_restarts + 1):
|
|
70
|
+
scale = _scale(p)
|
|
71
|
+
|
|
72
|
+
def residual(u: FloatArray, scale: FloatArray = scale) -> FloatArray:
|
|
73
|
+
return problem.weighted_residual(dict(zip(names, u * scale, strict=True)))
|
|
74
|
+
|
|
75
|
+
# Trial points may overflow; the solver rejects such steps by itself, and the final
|
|
76
|
+
# result is checked separately, so floating-point warnings here are only noise.
|
|
77
|
+
with np.errstate(all="ignore"):
|
|
78
|
+
run = least_squares(
|
|
79
|
+
residual,
|
|
80
|
+
p / scale,
|
|
81
|
+
jac="3-point" if self.method == "trf" else partial(_central_jacobian, residual),
|
|
82
|
+
bounds=(lo / scale, hi / scale) if self.method == "trf" else (-np.inf, np.inf),
|
|
83
|
+
method=self.method,
|
|
84
|
+
xtol=self.tol,
|
|
85
|
+
ftol=self.tol,
|
|
86
|
+
gtol=self.tol,
|
|
87
|
+
max_nfev=self.max_nfev,
|
|
88
|
+
)
|
|
89
|
+
nfev += int(run.nfev)
|
|
90
|
+
cost = 2 * float(run.cost)
|
|
91
|
+
if result is not None and not cost < best_cost * (1 - 1e-13):
|
|
92
|
+
log.append(f"restart {attempt}: no further improvement (χ² = {cost:.10g})")
|
|
93
|
+
break
|
|
94
|
+
label = "initial run" if attempt == 0 else f"restart {attempt}"
|
|
95
|
+
log.append(f"{label}: χ² = {cost:.10g}, {run.nfev} evals, status {run.status}")
|
|
96
|
+
best_cost, result, best_scale = cost, run, scale
|
|
97
|
+
p = np.clip(run.x * scale, lo, hi)
|
|
98
|
+
|
|
99
|
+
assert result is not None
|
|
100
|
+
values = dict(zip(names, map(float, p), strict=True))
|
|
101
|
+
cov_u = _covariance(np.asarray(result.jac, dtype=float))
|
|
102
|
+
cov = None if cov_u is None else cov_u * np.outer(best_scale, best_scale)
|
|
103
|
+
if cov is None:
|
|
104
|
+
log.append("Jacobian is rank deficient; covariance not available")
|
|
105
|
+
return BackendResult(
|
|
106
|
+
values=values,
|
|
107
|
+
cov_unscaled=cov,
|
|
108
|
+
converged=bool(result.success),
|
|
109
|
+
nfev=nfev,
|
|
110
|
+
message=str(result.message),
|
|
111
|
+
method=_DESCRIPTIONS[self.method],
|
|
112
|
+
raw=result,
|
|
113
|
+
log=tuple(log),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _central_jacobian(fun: Callable[[FloatArray], FloatArray], u: FloatArray) -> FloatArray:
|
|
118
|
+
"""Central finite differences with step ``eps^(1/3) * max(1, |u|)`` (no bounds)."""
|
|
119
|
+
h = _EPS_CBRT * np.maximum(1.0, np.abs(u))
|
|
120
|
+
columns = []
|
|
121
|
+
for i, hi in enumerate(h):
|
|
122
|
+
step = np.zeros_like(u)
|
|
123
|
+
step[i] = hi
|
|
124
|
+
columns.append((fun(u + step) - fun(u - step)) / (2 * hi))
|
|
125
|
+
return np.column_stack(columns)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _scale(p: FloatArray) -> FloatArray:
|
|
129
|
+
return np.where(np.abs(p) > 0, np.abs(p), 1.0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
#: Relative singular value below which the Jacobian counts as rank deficient. Finite-difference
|
|
133
|
+
#: Jacobians of redundant parameters reach ~1e-12; with normalized parameters, the worst
|
|
134
|
+
#: legitimately ill-conditioned NIST problem (Nelson) stays at 3e-6.
|
|
135
|
+
RCOND = 1e-9
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _covariance(jac: FloatArray) -> FloatArray | None:
|
|
139
|
+
"""``(JᵀJ)⁻¹`` via SVD; ``None`` if ``J`` is numerically rank deficient."""
|
|
140
|
+
if jac.size == 0 or not np.all(np.isfinite(jac)):
|
|
141
|
+
return None
|
|
142
|
+
_, s, vt = np.linalg.svd(jac, full_matrices=False)
|
|
143
|
+
if s[-1] <= RCOND * s[0]:
|
|
144
|
+
return None
|
|
145
|
+
return np.asarray((vt.T / s**2) @ vt, dtype=float)
|