Thurber's electron mobility, a NIST certified fit
NIST's Statistical Reference Datasets give nonlinear regression problems
with parameters certified to eleven digits. Thurber (higher difficulty) is
a rational function of degree three over three, seven parameters, fitted
to 37 measurements of electron mobility against the log of density.
vp.optimize.curve_fit fits it from both of NIST's starting points with
MINPACK's Levenberg-Marquardt and an exact Jacobian, and the table compares
every parameter and standard deviation with the certified value.
The model and the data
The model is NIST's, written for SciPy in lib/minpack/nist_strd.py:
def rational3(x, b1, b2, b3, b4, b5, b6, b7):
return (b1 + b2 * x + b3 * x ** 2 + b4 * x ** 3) / (1.0 + b5 * x + b6 * x ** 2 + b7 * x ** 3)
py2viv translates it to Viv once; the driver differentiates it with
reverse-mode grad at each point, so the Jacobian has no step-size error.
import json
import sys
import numpy as np
import vipyr as vp
from vipyr.figure import figure
sys.path.insert(0, "lib/minpack")
import nist_strd as N # noqa: E402
p = N.load("Thurber")
print(
"Thurber: %d observations, %d parameters, %s difficulty"
% (len(p.y), len(p.certified), p.difficulty)
)
fits = [
vp.optimize.curve_fit(N.rational3, p.x, p.y, start) for start in p.starts
]
for k, r in enumerate(fits, 1):
print(
"start %d: %3d evaluations, %3d Jacobians, MINPACK info %d, residual "
"sum of squares %.10e" % (k, r.nfev, r.njev, r.info, r.rss)
)
print("certified residual sum of squares %.10e" % p.rss)Thurber: 37 observations, 7 parameters, higher difficulty
start 1: 33 evaluations, 27 Jacobians, MINPACK info 1, residual sum of squares 5.6427082614e+03
start 2: 18 evaluations, 17 Jacobians, MINPACK info 1, residual sum of squares 5.6427082568e+03
certified residual sum of squares 5.6427082397e+03Against the certified values
The digits that agree are NIST's log relative error, LRE = -log10(|value - certified| / |certified|). With leastsq's defaults lmder stops when the sum of squares falls by less than 1.5 × 10−8 relative (MINPACK info 1). Thurber's parameters are strongly correlated, so a sum of squares right to nine digits leaves them right to about five. NIST counts four or more digits as a successful fit.
r = fits[0]
lre, lre_sd = N.lre(r.popt, p.certified), N.lre(r.perr, p.cert_sd)
print(
" %22s %22s %5s %16s %16s %5s"
% ("certified", "vp.optimize", "LRE", "certified sd", "vp sd", "LRE")
)
for i in range(len(p.certified)):
print(
"b%d %22.10e %22.10e %5.1f %16.10e %16.10e %5.1f"
% (
i + 1,
p.certified[i],
r.popt[i],
lre[i],
p.cert_sd[i],
r.perr[i],
lre_sd[i],
)
) certified vp.optimize LRE certified sd vp sd LRE
b1 1.2881396800e+03 1.2881397251e+03 7.5 4.6647963344e+00 4.6648024297e+00 5.9
b2 1.4910792535e+03 1.4910709812e+03 5.3 3.9571156086e+01 3.9576874779e+01 3.8
b3 5.8323836877e+02 5.8323259612e+02 5.0 2.8698696102e+01 2.8702835321e+01 3.8
b4 7.5416644291e+01 7.5415515842e+01 4.8 5.5675370270e+00 5.5683609863e+00 3.8
b5 9.6629502864e-01 9.6628945360e-01 5.2 3.1333340687e-02 3.1337226644e-02 3.9
b6 3.9797285797e-01 3.9797031629e-01 5.2 1.4984928198e-02 1.4986795863e-02 3.9
b7 4.9727297349e-02 4.9725635312e-02 4.5 6.5842344623e-03 6.5852093944e-03 3.8Tighter tolerances, more digits
ftol and xtol are leastsq's. Each two decades smaller buys about one
certified digit, for a handful of extra evaluations.
for tol in (1e-10, 1e-12, 1e-14):
for k, start in enumerate(p.starts, 1):
t = vp.optimize.curve_fit(
N.rational3, p.x, p.y, start, ftol=tol, xtol=tol
)
print(
"ftol = xtol = %.0e, start %d: %2d evaluations, LRE of "
"parameters %.1f, of standard deviations %.1f"
% (
tol,
k,
t.nfev,
N.lre(t.popt, p.certified).min(),
N.lre(t.perr, p.cert_sd).min(),
)
)ftol = xtol = 1e-10, start 1: 40 evaluations, LRE of parameters 5.7, of standard deviations 5.0
ftol = xtol = 1e-10, start 2: 24 evaluations, LRE of parameters 5.6, of standard deviations 4.9
ftol = xtol = 1e-12, start 1: 46 evaluations, LRE of parameters 6.7, of standard deviations 6.1
ftol = xtol = 1e-12, start 2: 30 evaluations, LRE of parameters 6.6, of standard deviations 5.9
ftol = xtol = 1e-14, start 1: 51 evaluations, LRE of parameters 7.6, of standard deviations 6.9
ftol = xtol = 1e-14, start 2: 38 evaluations, LRE of parameters 7.9, of standard deviations 7.3Against SciPy
SciPy's curve_fit calls the same Fortran (lmdif, with differences), so
with jac="fd" the two take the same steps. The counts and the
certified digits of each are from lib/minpack/bench_optimize.py, over
all 27 NIST problems; the times are for one warm fit and depend on the
machine and its load, recorded with them.
bench = json.load(open("lib/minpack/bench_optimize.json"))
print(
"measured on",
bench["machine"],
"| load average",
bench["load_average_start"],
"| SciPy",
bench["scipy"],
)
print(
"%-9s %5s %22s %22s %22s"
% (
"problem",
"start",
"vp (grad): LRE nfev ms",
"vp (fd): LRE nfev ms",
"SciPy (fd): LRE nfev ms",
)
)
def cell(c):
return (
"%4.1f %5d %7.2f" % (c["lre"], c["nfev"], 1e3 * c["s"])
if c["ok"]
else "%22s" % "did not converge"
)
for row in bench["nist"]:
if row["difficulty"] == "higher":
print(
"%-9s %5d %22s %22s %22s"
% (
row["problem"],
row["start"],
cell(row["vp"]),
cell(row["vp_fd"]),
cell(row["scipy"]),
)
)measured on Apple M2 Pro, Darwin | load average 12.06 | SciPy 1.12.0
problem start vp (grad): LRE nfev ms vp (fd): LRE nfev ms SciPy (fd): LRE nfev ms
MGH09 1 4.1 494 1.32 4.0 1757 0.98 did not converge
MGH09 2 4.0 18 0.28 4.0 82 0.27 4.0 82 0.39
Thurber 1 4.5 33 0.80 4.6 230 1.35 4.5 222 2.04
Thurber 2 4.5 18 0.59 4.5 137 0.89 4.5 137 1.28
BoxBOD 1 0.0 10 0.46 0.0 10 0.24 0.0 10 0.07
BoxBOD 2 5.1 9 0.24 5.1 25 0.23 5.1 25 0.11
Rat42 1 6.8 10 0.27 6.8 34 0.29 6.8 34 0.37
Rat42 2 7.1 6 0.79 7.1 21 0.83 7.1 21 0.11
MGH10 1 9.8 286 1.08 6.9 1008 0.92 did not converge
MGH10 2 9.0 126 0.63 7.3 474 0.52 7.9 474 1.36
Eckerle4 1 7.2 18 0.41 7.2 63 0.33 7.2 63 0.28
Eckerle4 2 7.2 7 0.30 7.2 25 0.28 7.2 25 0.14
Rat43 1 5.1 26 0.42 5.1 102 0.41 5.1 102 0.46
Rat43 2 4.9 7 0.30 4.9 31 0.30 4.9 31 0.17
Bennett5 1 did not converge 5.0 3000 26.56 did not converge
Bennett5 2 4.1 203 8.74 5.4 790 7.29 5.7 787 4.15The fit and its residuals
The curve through the data, and the residuals y - f(x) against x. NIST's residual standard deviation for Thurber is 13.71; the fit's is printed below it.
xs = np.linspace(p.x.min(), p.x.max(), 200)
fig = figure(
"Thurber: electron mobility against log density", "log density", "mobility"
)
fig.scatter(p.x, p.y, "NIST data")
fig.plot(xs, N.rational3(xs, *r.popt), "vp.optimize fit")
fig.style("vp.optimize fit", "s2 nomarker")
res = p.y - N.rational3(p.x, *r.popt)
print(
"residual standard deviation %.4f (NIST, certified: 1.3714600784E+01)"
% np.sqrt(res @ res / (len(p.y) - len(p.certified)))
)
fig2 = figure("Residuals of the fit", "log density", "y - f(x)")
fig2.scatter(p.x, res, "residual")
fig2.hline("zero", 0.0)residual standard deviation 13.7146 (NIST, certified: 1.3714600784E+01)Credits
- A port of MINPACK (Argonne National Laboratory), under MINPACK's BSD-style copyright notice (1999, University of Chicago), which the port and the Fortran in lib/minpack/ref both carry. This product includes software developed by the University of Chicago, as Operator of Argonne National Laboratory.
Source
gallery/math/plot_nist_thurber_fit.py, run as written when these pages were built. The output
and the figures above are from that run.
The Viv program it calls is posts/2026-09-25-fitting-electron-mobility-with-minpack.viv: its atlas, the defs and the calls between them, or open it in the workbench.