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 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'")
@@ -0,0 +1,6 @@
1
+ """Solver backends behind :func:`fitwise.fit`."""
2
+
3
+ from fitwise.backends.base import Backend, BackendResult, FitProblem
4
+ from fitwise.backends.least_squares import LeastSquaresBackend
5
+
6
+ __all__ = ["Backend", "BackendResult", "FitProblem", "LeastSquaresBackend"]
@@ -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)