Foundations
Levenberg–Marquardt Algorithm
The damped Gauss–Newton method for nonlinear least squares as a procedure, with its accept-and-reject logic, damping updates, scaling matrix, trust-region form and stopping tests, as MINPACK, SciPy and Ceres Solver implement them.
intermediate
The Levenberg–Marquardt algorithm (LM) minimizes a sum of squared residuals by repeating one move: linearize the residuals, solve a damped version of the Gauss–Newton system for a step, try the step, and keep or reject it depending on how well the linear model predicted the decrease. The damping is raised after a poor prediction and lowered after a good one, so the method behaves like a short gradient-descent step far from the solution and like Gauss–Newton near it. Optimization calls Gauss–Newton and LM the standard solvers of calibration and bundle adjustment, and LM also refines poses, as in OpenCV’s default solver described in Perspective-n-Point. Nonlinear least squares derives the Gauss–Newton step, the damped normal equations and the gain ratio; this article treats LM as a procedure: what an iteration does, how the damping and the scaling matrix are chosen, what the stopping tests measure, and how three implementations (MINPACK, reached in Python through SciPy, and Ceres Solver) differ.
The method is named after Kenneth Levenberg, who proposed damping in 1944, and Donald Marquardt, who proposed it again in 1963, as Nonlinear Least Squares reports. Ceres Solver’s documentation calls it “the most popular algorithm for solving non-linear least squares problems” and says it was also the first trust-region algorithm to be developed, citing both papers. Both papers, and Jorge Moré’s 1978 paper on the algorithm’s implementation and theory, are cited here without having been read: the statements below about MINPACK come from SciPy’s C translation of its source.
Problem
Given residuals of parameters, find a local minimizer of
using only the residuals and their Jacobian . Gauss–Newton solves the same problem but can take steps that increase , and fails when is singular; LM adds the safeguards that make the iteration descend from a wider range of starting points. It remains a local method: it finds a stationary point near its start, not the global minimum.
Inputs and Outputs
- Inputs: a residual function, its Jacobian (analytic, automatic, or by finite differences), a starting point, an initial damping value or trust-region radius, a scaling rule for the parameters, tolerances, and an evaluation budget.
- Outputs: the final parameters, the final cost, the reason for stopping (which test fired, or the budget ran out), and counts of residual and Jacobian evaluations. SciPy’s
least_squaresalso returns the final Jacobian, from which a covariance of the estimate can be formed when the noise level is known or estimated.
Intuition
Each iteration trusts a linear model of the residuals, but only so far. The damping (or, equivalently, a trust-region radius ) expresses how far: a large gives a short step along the negative gradient, a small one gives the full Gauss–Newton step. After each trial the algorithm compares the decrease it got with the decrease the linear model promised. A ratio near one means the model was right and can be trusted further; a small or negative ratio means it was wrong, the step is shortened and, if the cost went up, thrown away.
The second ingredient is the scaling matrix , which decides what “short” means. Parameters in vision come in very different units: an amplitude in thousands of grey levels, a position in pixels, a rotation in radians, a focal length in hundreds of pixels. Damping with the identity treats a change of one count in amplitude like a change of one pixel in position. Damping with a diagonal built from the Jacobian’s columns measures every parameter by how strongly it moves the residuals, which, by the derivation below, makes the step independent of the units the parameters happen to be written in.
Algorithm
The damped form, with an explicit damping parameter, as Nonlinear Least Squares presents it and as hand-written implementations often code it:
- Choose a start , a damping , a scaling rule , and thresholds .
- Evaluate , , the gradient and at the current point. Stop if a convergence test holds (see below).
- Solve .
- Evaluate the cost at and the gain ratio (actual over predicted decrease).
- Update the damping: decrease if , increase it if .
- If , accept the step and go to 2 (a new Jacobian); otherwise keep and go to 3 with the larger (the same Jacobian).
MINPACK’s LMDER, the Fortran routine of Moré, Garbow and Hillstrom that SciPy’s least_squares(method='lm') calls in a C translation, is written as a trust-region method instead. In the SciPy v1.16.3 source each outer iteration does the following:
- Evaluate the Jacobian and factor it once by QR with column pivoting.
- On the first iteration, set the scaling to the column norms of the Jacobian (or to user-supplied values) and the radius to , with factor 100 in SciPy; afterwards, keep each entry of as the running maximum of that column’s norm.
- Test the gradient: stop if the largest cosine between the residual vector and a Jacobian column is at most
gtol. - Inner loop: find the Levenberg–Marquardt parameter (MINPACK’s
par) whose step satisfies (routine LMPAR, below), try the step, and compute the ratio of actual to predicted reduction, both relative to . On the first iteration, shrink to if that is smaller. - If the ratio is at most 0.25, set to a factor between 0.1 and 0.5 of , the factor chosen by a quadratic interpolation of the cost along the step, and raise by its inverse; otherwise, if the ratio is at least 0.75 or , set and halve .
- Accept the step if the ratio is at least ; otherwise repeat the inner loop with the smaller , reusing the factorization.
- Stop when both the actual and predicted relative reductions are at most
ftol(with the ratio at most 2), when , or when the number of residual evaluations reaches the budget; other exit codes report tolerances too small for machine precision.
LMPAR returns , the Gauss–Newton step, when that step already satisfies ; otherwise it searches for with , giving up after ten iterations with the best value found, and solves each damped system from the QR factor already computed, so trying a new does not refactor the Jacobian.
Mathematical Formulation
The damped step and the trust region
The step solves the damped normal equations
which are the normal equations of the augmented linear least-squares problem . MINPACK solves that augmented problem, with stacked under and zeros under , from its QR factor of rather than forming , and Ceres’ documentation writes its subproblem the same way. With this is the system of Nonlinear Least Squares; with it is the variant that article mentions in one sentence, which damps each parameter in proportion to its own curvature.
The trust-region problem is
In our derivation, its Lagrangian conditions are the damped equations above with a multiplier and : either the Gauss–Newton step lies inside the region and , or the step lies on its boundary with some . Every damped step is therefore a trust-region step for the radius , the equivalence Nonlinear Least Squares attributes to Madsen, Nielsen and Tingleff. LM thus belongs to the trust-region family that optimization contrasts with line searches. The two descriptions differ only in which quantity the algorithm updates: a damping-form implementation changes directly; MINPACK changes and solves for .
Gain ratio
With , the linear model predicts the decrease
For a damped step (substituting the damped equations, our derivation), so a negative ratio always means the cost rose. MINPACK writes both reductions relative to ; Ceres’ documentation writes the ratio with differences of , which is the same quantity.
Invariance under a rescaling of the parameters
Write the parameters in other units, with diagonal (kilo-counts instead of counts, millimetres instead of metres). Then , and, because is diagonal, . With the damped system in the new units is
the same step expressed in the new units (our derivation). The cost and the predicted decrease do not change, so neither do the gain ratio, the damping updates or any stopping test that compares relative quantities; in exact arithmetic the whole sequence of iterates maps by . With the new system is : LM in the new units is LM in the old units with , a different algorithm. MINPACK’s default scaling, the running maximum of column norms, also transforms as , and its first radius is unchanged, so by the same argument its iterates are invariant; with they are not, except at steps where , since the undamped Gauss–Newton step is invariant under any linear reparameterization. The example below checks it on 100 starts (H1).
Mapping between conventions
| Implementation | Step equation | Damping quantity | Scaling |
|---|---|---|---|
| Nonlinear Least Squares | (or ) | ||
MINPACK (par, radius ) |
, | , found from | running max of squared column norms, or in SciPy |
| Ceres Solver (radius ) | squared column norms clamped to |
SciPy’s x_scale is the reciprocal of MINPACK’s diag: least_squares.py passes diag = 1 / x_scale, and x_scale='jac' passes no diag, which selects MINPACK’s column-norm scaling. Ceres’ levenberg_marquardt_strategy.cc computes the squared column norms of , clamps them to [min_lm_diagonal, max_lm_diagonal], and passes to the linear solver as the diagonal of the damped problem; with the documentation’s augmented form, which stacks this under , the normal equations carry . The documentation, in our reading, is not consistent with this by a factor of two: it writes the subproblem as “”, whose normal equations would carry , but then appends “” to the Jacobian, which gives , as the code does.
Parameters
- Initial damping or radius. A hand-written LM needs . With , is dimensionless and a value such as means “start close to Gauss–Newton”; with it carries the units of and has to be tied to the problem, for example as a fraction of the largest diagonal entry. MINPACK takes the radius from
factor(its documentation recommends values in , with 100 “generally recommended”), and SciPy fixesfactor = 100. Ceres’initial_trust_region_radiusdefaults to , and its documentation states that the reciprocal, , is the initial regularization parameter. - Update rule. MINPACK: shrink by 0.1 to 0.5 when the ratio is at most 0.25, double it relative to the step when the ratio is at least 0.75. Ceres’ LM strategy (in
levenberg_marquardt_strategy.cc), on an accepted step of quality , divides the radius by , so that it grows by up to a factor of 3 after a good step and shrinks by up to a factor of 2 after a barely acceptable one (capped atmax_trust_region_radius, default ) and resets a decrease factor to 2; on a rejected step it divides the radius by and doubles , so repeated failures shrink the radius ever faster. The documentation’s generic trust-region template, which doubles or halves the radius, describes the family, not this strategy’s rule. - Acceptance threshold. MINPACK accepts a step when the ratio is at least ; Ceres’
min_relative_decreasedefaults to . - Scaling.
x_scale='jac'(MINPACK’s column norms) or a vector of characteristic parameter sizes. SciPy 1.16.0 changed the default formethod='lm'fromx_scale=1to'jac';'trf'and'dogbox'still default to 1. SciPy’s documentation says that changing the scale is equivalent to solving in variables . - Stopping tolerances. In
least_squareswithmethod='lm',ftolbounds the relative reduction of the cost,xtolthe radius relative to , andgtolthe largest cosine between the residuals and a Jacobian column; all three default to and must exceed machine epsilon.max_nfevdefaults to residual evaluations; SciPy 1.16.0 made this the'lm'default also for a finite-difference Jacobian (previously ) and stopped counting the finite-difference evaluations in it. - Restrictions in SciPy.
method='lm'rejects bounds, robust losses other than'linear', sparse Jacobians and problems with ;'trf'and'dogbox'handle those cases.
Complexity
Each outer iteration costs one Jacobian evaluation and one dense factorization: for MINPACK’s QR of the Jacobian. Each further trial of inside an iteration costs one residual evaluation plus LMPAR’s solves (at most ten) with the stored triangular factor, each of which eliminates by Givens rotations in operations (our count from qrsolv), so a new never repeats the factorization of , and for the inner loop is cheap compared with a new Jacobian. An implementation that forms once and refactors for every also pays per trial (our count); Ceres’ documentation notes that its dog-leg strategy needs only one linear solve per successful step, whereas LM may need many. For large sparse problems such as bundle adjustment, the cost of the linear solve dominates and is reduced by the Schur complement, which bundle adjustment describes, or by an inexact step: Ceres’ documentation describes an inexact-step LM that solves the damped system approximately with an iterative solver and stops it with a forcing sequence, citing a convergence result of Wright and Holt.
Implementation
The example fits a 2-D Gaussian spot, the kind of fit used to locate stars, fluorescent markers or calibration dots to a fraction of a pixel, and asks how the scaling matrix behaves when the parameters have very different sizes. A patch holds a spot of amplitude counts at px with width px on a background counts, plus Gaussian noise with counts (one draw, seed 0). The amplitude and the width differ in size by a factor of 625. The same fit is then posed in a second parameterization, P′, with the amplitude in kilo-counts (), and five solvers run from one grid of 100 starts (centre offsets of 0, ±2 and ±4 px in each axis and starting widths of 0.8, 1.6, 3.2 and 6.4 px; amplitude and background from the image’s maximum and median) in both parameterizations:
- LM-I and LM-diag, a hand-written LM with and with , using our own update rule (divide by 3 when , double it when , accept when ) and stopping tests chosen to be invariant to a rescaling;
- MINPACK-jac and MINPACK-1, SciPy’s
method='lm'withx_scale='jac'andx_scale=1.0; - TRF-1, SciPy’s trust-region-reflective method with
x_scale=1.0, its default.
Every point at which a solver evaluates the residuals is recorded, converted back to the units of P, and compared between the two parameterizations. Differences are measured in standard errors of the parameters at the reference minimum, , a unit that does not depend on how a parameter is written. Conventions: pixel centres at integer coordinates, the column and the row (downward); the model is sampled at pixel centres, not integrated over pixels; and give the same spot, so comparisons use . The code uses NumPy 2.3.5 and SciPy 1.16.3 and takes about 11 seconds on a shared machine.
import os
for var in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ[var] = "1" # one BLAS thread, before NumPy loads
import numpy as np
from scipy.optimize import least_squares
np.seterr(all="ignore") # far starts can drive s to 0
# A 21 x 21 patch with a Gaussian spot; pixel centres at integers, x = column, y = row (down).
yy, xx = np.mgrid[0:21, 0:21].astype(float)
TRUE = np.array([1000.0, 10.3, 9.6, 1.6, 100.0]) # A (counts), x0, y0 (px), s (px), B (counts)
S = np.array([1e-3, 1, 1, 1, 1]) # P' = S * P: amplitude in kilo-counts
NAMES = ["A", "x0", "y0", "s", "B"]
def model(t):
A, x0, y0, s, B = t
g = np.exp(-((xx - x0) ** 2 + (yy - y0) ** 2) / (2 * s ** 2))
return B + A * g, g
def jacobian(t): # d model / d (A, x0, y0, s, B), P units
A, x0, y0, s, B = t
g, dx, dy = model(t)[1], xx - x0, yy - y0
cols = [g, A * g * dx / s**2, A * g * dy / s**2, A * g * (dx**2 + dy**2) / s**3, np.ones_like(g)]
return np.column_stack([c.ravel() for c in cols])
clean = model(TRUE)[0]
noise = np.random.default_rng(0).normal(0.0, 10.0, clean.shape)
noisy = clean + noise
class Problem:
"""Residuals and Jacobian in variables u = scale * theta; records every evaluation in P units."""
def __init__(self, image, scale):
self.image, self.scale, self.points = image.ravel(), scale, []
def res(self, u):
t = u / self.scale
self.points.append(t.copy())
return model(t)[0].ravel() - self.image
def jac(self, u):
return jacobian(u / self.scale) / self.scale # J' = J S^-1
def lm(prob, u0, damping, max_nfev=1000):
"""Hand-written LM: (J^T J + lam D^2) d = -J^T r with D = I or D^2 = diag(J^T J)."""
u = np.array(u0, float)
r = prob.res(u); F = 0.5 * r @ r; nfev, it = 1, 0
J = prob.jac(u); H = J.T @ J
lam = 1e-3 if damping == "diag" else 1e-3 * H.diagonal().max()
lam_cap = 1e20 if damping == "diag" else 1e20 * H.diagonal().max()
while True:
g, rn = J.T @ r, np.linalg.norm(r)
cosine = np.abs(g) / np.maximum(np.linalg.norm(J, axis=0) * rn, 1e-300)
if rn == 0 or cosine.max() <= 1e-12: # gradient test
return u, F, nfev, it, True
D2 = H.diagonal() if damping == "diag" else np.ones(len(u))
while True: # inner loop: retry with more damping
if nfev >= max_nfev or lam > lam_cap:
return u, F, nfev, it, False
try:
d = np.linalg.solve(H + lam * np.diag(D2), -g)
except np.linalg.LinAlgError:
return u, F, nfev, it, False
rt = prob.res(u + d); Ft = 0.5 * rt @ rt; nfev += 1
pred = -(g @ d + 0.5 * d @ H @ d) # decrease predicted by the linear model
rho = (F - Ft) / pred if pred > 0 else -1.0 # gain ratio
if rho > 0.75:
lam /= 3
elif rho < 0.25:
lam *= 2
if rho > 1e-4:
break # accept the step
dec = (F - Ft) / F if F > 0 else 0.0
u, r, F, it = u + d, rt, Ft, it + 1
if dec < 1e-12 or np.max(np.abs(d) / np.maximum(np.abs(u), 1e-12)) < 1e-10:
return u, F, nfev, it, True # cost or step test
J = prob.jac(u); H = J.T @ J
def scipy_fit(prob, u0, method, x_scale):
try:
s = least_squares(prob.res, u0, jac=prob.jac, method=method, x_scale=x_scale,
ftol=1e-8, xtol=1e-8, gtol=1e-8, max_nfev=1000)
except (ValueError, np.linalg.LinAlgError):
return np.full(5, np.nan), np.inf, len(prob.points), 0, False
return s.x, s.cost, s.nfev, s.njev, s.status > 0
METHODS = {
"LM-I": lambda p, u0: lm(p, u0, "identity"),
"LM-diag": lambda p, u0: lm(p, u0, "diag"),
"MINPACK-jac": lambda p, u0: scipy_fit(p, u0, "lm", "jac"),
"MINPACK-1": lambda p, u0: scipy_fit(p, u0, "lm", 1.0),
"TRF-1": lambda p, u0: scipy_fit(p, u0, "trf", 1.0),
}
PARAMS = {"P": np.ones(5), "P'": S}
def fold(t): # s and -s give the same spot
return np.r_[t[:3], abs(t[3]), t[4]]
def cost(t, image):
return 0.5 * np.sum((model(t)[0] - image) ** 2)
def moment_start(image, dx, dy, s0):
return np.array([image.max() - np.median(image), 10.3 + dx, 9.6 + dy, s0, np.median(image)])
# ---- data checks ----
print(f"D1 noise SD = {noise.std():.3f} counts (bar |SD - 10| < 1.0): "
f"{'held' if abs(noise.std() - 10) < 1.0 else 'FAILED'}")
ratio = np.abs(TRUE).max() / np.abs(TRUE).min()
cn = np.linalg.norm(jacobian(TRUE), axis=0)
print(f"D2 parameter magnitude ratio in P = {ratio:.1f} (bar 300-3000): "
f"{'held' if 300 <= ratio <= 3000 else 'FAILED'}; Jacobian column-norm ratio at truth: "
f"P {cn.max() / cn.min():.1f}, P' {(cn / S).max() / (cn / S).min():.1f}")
# ---- exact-data and Jacobian checks ----
worst = 0.0
for name, fit in METHODS.items():
for key, sc in PARAMS.items():
prob = Problem(clean, sc)
x, F = fit(prob, moment_start(clean, 1, -1, 2.4) * sc)[:2]
err = np.max(np.abs(fold(x / sc) - TRUE) / np.abs(TRUE))
worst = max(worst, err)
print(f"C1 noise-free {name:11s} {key:2s}: max rel error {err:.1e}, cost {F:.1e}: "
f"{'held' if err < 1e-6 and F < 1e-6 else 'FAILED'}")
t1 = moment_start(noisy, 1, -1, 2.4)
Jfd = np.column_stack([(model(t1 + h)[0] - model(t1 - h)[0]).ravel() / (2 * h.sum())
for h in np.diag(1e-6 * np.maximum(np.abs(t1), 1))])
c2 = np.abs(Jfd - jacobian(t1)).max() / np.abs(jacobian(t1)).max()
print(f"C2 Jacobian against central differences: {c2:.1e} (bar 1e-6): {'held' if c2 < 1e-6 else 'FAILED'}")
a, b = Problem(noisy, PARAMS["P"]), Problem(noisy, PARAMS["P"])
sa = least_squares(a.res, t1, jac=a.jac, method="lm", ftol=1e-8, xtol=1e-8, gtol=1e-8, max_nfev=1000)
sb = least_squares(b.res, t1, jac=b.jac, method="lm", x_scale="jac", ftol=1e-8, xtol=1e-8,
gtol=1e-8, max_nfev=1000)
c4 = np.abs(sa.x - sb.x).max()
print(f"C4 'lm' default x_scale against 'jac': max |dx| = {c4:.1e}, nfev {sa.nfev} and {sb.nfev}: "
f"{'held' if c4 == 0.0 and sa.nfev == sb.nfev else 'FAILED'}")
for label, kw in (("loss='huber'", {"loss": "huber"}), ("bounds", {"bounds": (0, np.inf)})):
try:
least_squares(a.res, t1, method="lm", **kw); raised = False
except ValueError:
raised = True
print(f"C5 method='lm' with {label} raises ValueError: {'held' if raised else 'FAILED'}")
# ---- reference minimum on the noisy image ----
ref_prob = Problem(noisy, PARAMS["P"])
r1 = lm(ref_prob, TRUE, "diag")[0]
r2 = least_squares(ref_prob.res, r1, jac=ref_prob.jac, method="lm", x_scale="jac",
ftol=1e-15, xtol=1e-15, gtol=1e-15).x
t_ref = fold(min((r1, r2), key=lambda t: cost(t, noisy)))
F_ref = cost(t_ref, noisy)
Jr = jacobian(t_ref)
SE = 10.0 * np.sqrt(np.diag(np.linalg.inv(Jr.T @ Jr)))
print("\nreference minimum " + " ".join(f"{n}={v:.4f}" for n, v in zip(NAMES, t_ref)))
print("standard errors " + " ".join(f"{n}={v:.4f}" for n, v in zip(NAMES, SE)))
print("|ref - truth| / SE " + " ".join(f"{n}={v:.2f}" for n, v in zip(NAMES, np.abs(t_ref - TRUE) / SE)))
print(f"F_ref = {F_ref:.2f}; 0.5 sigma^2 (m - n) = {0.5 * 100 * (441 - 5):.2f}")
# ---- all methods, both parameterizations, 100 starts ----
starts = [moment_start(noisy, dx, dy, s0) for dx in (-4, -2, 0, 2, 4)
for dy in (-4, -2, 0, 2, 4) for s0 in (0.8, 1.6, 3.2, 6.4)]
runs = {}
for name, fit in METHODS.items():
for key, sc in PARAMS.items():
rows = []
for u0 in starts:
prob = Problem(noisy, sc)
x, F, nfev, it, conv = fit(prob, u0 * sc)
rows.append(dict(t=fold(x / sc), F=F, nfev=nfev, it=it, conv=conv, path=np.array(prob.points)))
runs[name, key] = rows
print("\nmethod par conv reach else(min F/Fref) nfev med max iters med capped "
"max dF/Fref max dtheta/SE")
h2 = True
for (name, key), rows in runs.items():
conv = [r for r in rows if r["conv"]]
reach = [r for r in rows if r["F"] < 1.01 * F_ref]
good = [r for r in conv if r["F"] < 1.01 * F_ref]
other = [r["F"] / F_ref for r in conv if r["F"] >= 1.01 * F_ref]
dF = max(abs(r["F"] - F_ref) / F_ref for r in good) if good else np.nan
dt = max(np.max(np.abs(r["t"] - t_ref) / SE) for r in good) if good else np.nan
h2 &= bool(good) and dF < 1e-9 and dt < 0.01
nf = [r["nfev"] for r in rows]
print(f"{name:11s} {key:3s} {len(conv):4d} {len(reach):5d} {len(other):4d}"
f"({min(other) if other else np.nan:9.3g}) {np.median(nf):8.1f} {max(nf):4d}"
f" {np.median([r['it'] for r in rows]):9.1f} {sum(n >= 1000 for n in nf):6d}"
f" {dF:11.1e} {dt:13.2e}")
print(f"min final cost over all runs / F_ref - 1 = "
f"{min(r['F'] for rows in runs.values() for r in rows) / F_ref - 1:.1e} "
f"(C3 bar >= -1e-12): {'held' if min(r['F'] for rows in runs.values() for r in rows) >= F_ref * (1 - 1e-12) else 'FAILED'}")
print("\ninvariance, P against P' (differences in standard errors)")
inv = {}
for name in METHODS:
eq, maxd, differ, first = 0, 0.0, 0, []
for a, b in zip(runs[name, "P"], runs[name, "P'"]):
pa, pb = a["path"], b["path"]
ia = next((k for k in range(1, len(pa)) if np.any(pa[k] != pa[0])), None)
ib = next((k for k in range(1, len(pb)) if np.any(pb[k] != pb[0])), None)
if ia is not None and ib is not None: # first trial point after the start
first.append(np.max(np.abs(pa[ia] - pb[ib]) / SE))
if len(pa) == len(pb):
eq += 1
d = np.max(np.abs(pa - pb) / SE)
maxd = max(maxd, d)
differ += d > 0.1
else:
differ += 1
inv[name] = (eq, maxd, differ)
print(f"{name:11s} equal counts {eq:3d}/100 max path diff {maxd:.1e} "
f"paths differ {differ:3d}/100 min first-trial diff {min(first):.2e}")
med = {(m, k): np.median([r["nfev"] for r in runs[m, k]]) for m in ("MINPACK-jac", "MINPACK-1") for k in PARAMS}
print()
for h, name in (("H1a", "LM-diag"), ("H1b", "MINPACK-jac")):
eq, maxd, _ = inv[name]
print(f"{h} {name} invariant (100/100 equal, max diff < 1e-6): {'held' if eq == 100 and maxd < 1e-6 else 'failed'}")
print(f"H1c LM-I paths differ on 100/100: {'held' if inv['LM-I'][2] == 100 else 'failed'}")
for h, name in (("H1d", "MINPACK-1"), ("H1e", "TRF-1")):
print(f"{h} {name} paths differ on >= 1 start: {'held' if inv[name][2] >= 1 else 'failed'}")
print(f"H2 every converged run below 1.01 F_ref is at the reference: {'held' if h2 else 'failed'}")
for h, key in (("H3a", "P"), ("H3b", "P'")):
j, o = med["MINPACK-jac", key], med["MINPACK-1", key]
print(f"{h} {key}: median nfev jac {j:.1f} <= x_scale=1 {o:.1f}: {'held' if j <= o else 'failed'}")
Output:
D1 noise SD = 10.064 counts (bar |SD - 10| < 1.0): held
D2 parameter magnitude ratio in P = 625.0 (bar 300-3000): held; Jacobian column-norm ratio at truth: P 883.9, P' 135.0
C1 noise-free LM-I P : max rel error 5.1e-14, cost 5.2e-21: held
C1 noise-free LM-I P': max rel error 1.4e-15, cost 3.9e-24: held
C1 noise-free LM-diag P : max rel error 0.0e+00, cost 0.0e+00: held
C1 noise-free LM-diag P': max rel error 0.0e+00, cost 0.0e+00: held
C1 noise-free MINPACK-jac P : max rel error 1.1e-16, cost 1.1e-25: held
C1 noise-free MINPACK-jac P': max rel error 0.0e+00, cost 0.0e+00: held
C1 noise-free MINPACK-1 P : max rel error 1.1e-16, cost 1.1e-25: held
C1 noise-free MINPACK-1 P': max rel error 0.0e+00, cost 0.0e+00: held
C1 noise-free TRF-1 P : max rel error 1.1e-16, cost 1.1e-25: held
C1 noise-free TRF-1 P': max rel error 0.0e+00, cost 0.0e+00: held
C2 Jacobian against central differences: 9.2e-11 (bar 1e-6): held
C4 'lm' default x_scale against 'jac': max |dx| = 0.0e+00, nfev 7 and 7: held
C5 method='lm' with loss='huber' raises ValueError: held
C5 method='lm' with bounds raises ValueError: held
reference minimum A=1001.8015 x0=10.2922 y0=9.6048 s=1.5889 B=100.1898
standard errors A=5.0218 x0=0.0080 y0=0.0080 s=0.0059 B=0.5146
|ref - truth| / SE A=0.36 x0=0.98 y0=0.60 s=1.90 B=0.37
F_ref = 22008.37; 0.5 sigma^2 (m - n) = 21800.00
method par conv reach else(min F/Fref) nfev med max iters med capped max dF/Fref max dtheta/SE
LM-I P 100 84 16( 169) 14.0 247 13.0 0 8.3e-16 9.81e-08
LM-I P' 79 64 16( 166) 20.0 1000 15.0 20 9.9e-16 2.87e-07
LM-diag P 78 64 14( 166) 23.0 1000 12.0 11 9.9e-16 3.76e-07
LM-diag P' 77 64 13( 166) 23.0 1000 12.0 11 1.2e-15 3.76e-07
MINPACK-jac P 86 63 23( 166) 13.0 1000 10.0 14 2.6e-12 2.89e-05
MINPACK-jac P' 86 63 23( 166) 13.0 1000 10.0 14 2.6e-12 2.89e-05
MINPACK-1 P 83 61 22( 166) 393.0 1000 357.5 17 2.9e-12 2.54e-05
MINPACK-1 P' 81 66 15( 166) 14.0 1000 10.0 19 2.7e-12 2.17e-05
TRF-1 P 43 37 6( 166) 1000.0 1000 967.5 57 2.9e-12 2.63e-05
TRF-1 P' 84 67 17( 166) 13.0 1000 11.0 16 2.6e-12 2.35e-05
min final cost over all runs / F_ref - 1 = -1.1e-15 (C3 bar >= -1e-12): held
invariance, P against P' (differences in standard errors)
LM-I equal counts 1/100 max path diff 1.4e+02 paths differ 100/100 min first-trial diff 1.05e+01
LM-diag equal counts 96/100 max path diff 1.9e+157 paths differ 23/100 min first-trial diff 2.26e-14
MINPACK-jac equal counts 98/100 max path diff 6.9e+03 paths differ 16/100 min first-trial diff 3.79e-14
MINPACK-1 equal counts 15/100 max path diff 7.7e+04 paths differ 94/100 min first-trial diff 3.79e-14
TRF-1 equal counts 27/100 max path diff 4.3e+04 paths differ 91/100 min first-trial diff 2.76e-14
H1a LM-diag invariant (100/100 equal, max diff < 1e-6): failed
H1b MINPACK-jac invariant (100/100 equal, max diff < 1e-6): failed
H1c LM-I paths differ on 100/100: held
H1d MINPACK-1 paths differ on >= 1 start: held
H1e TRF-1 paths differ on >= 1 start: held
H2 every converged run below 1.01 F_ref is at the reference: held
H3a P: median nfev jac 13.0 <= x_scale=1 393.0: held
H3b P': median nfev jac 13.0 <= x_scale=1 14.0: held
Checks. The noise has sample standard deviation 10.064 (check D1 held) and the parameter magnitudes differ by a factor of 625 (D2 held); the Jacobian’s column norms at the truth differ by a factor of 883.9 in P and 135.0 in P′, so P is the more badly scaled problem. On the noise-free image every solver recovers the true parameters in both parameterizations (C1 held in all ten rows), the analytic Jacobian matches central differences (C2 held), no run ends below the reference cost (C3 held), SciPy 1.16.3’s method='lm' without x_scale gives exactly the result of x_scale='jac' (C4 held), and method='lm' refuses a Huber loss and bounds (C5 held). The reference minimum lies within 1.90 standard errors of the truth in every parameter, and its cost, 22,008.37, is close to , approximately the value expected for a correct model at the true noise level (our addition).
The same minimum (H2 held). In every row, every converged run that ends near the reference cost agrees with the reference to within in relative cost and standard errors in every parameter, against bars of and 0.01. The hand-written solvers agree more closely (at most and ), which in our reading follows from their tighter stopping thresholds ( and against SciPy’s ). Damping and scaling change the path and the evaluation count, not the estimate. Every solver also stops by its own tests, from some starts, at a point of higher cost (the “else” column, whose smallest cost ratio is 166 in every row except LM-I in P, 169), and the number of the 100 starts that reach the minimum (the “reach” column) depends on the solver and the parameterization; Nonlinear Least Squares’ circle example studies such basins.
Evaluation counts (H3a and H3b held). MINPACK with column-norm scaling needs a median of 13 evaluations in both parameterizations. With x_scale=1.0 it needs 14 in P′ but 393 in P, and TRF-1, whose trust region is also unscaled, needs a median of 1,000 in P, hitting the budget on 57 of 100 starts, against 13 in P′. Both rows of MINPACK-jac print identical summaries; the two LM-diag rows differ by one converged run that ends away from the reference (78 against 77 converged, 14 against 13 in the “else” column) and in the cost-agreement column. LM-I is the exception that the derivation predicts: its two rows differ (reach 84 in P against 64 in P′) because in P′ it is LM with a different damping matrix and a different initial damping, by construction.
Invariance (H1a and H1b failed; H1c, H1d and H1e held). The planned check of the derivation was that, for LM-diag (H1a) and MINPACK-jac (H1b), every start would give the same number of evaluations in both parameterizations and paths that agree to within standard errors. Both checks failed. LM-diag gave equal counts on 96 of 100 starts, a largest path difference of standard errors, and paths that differ by more than 0.1 standard errors, or in length, on 23 of 100 starts. MINPACK-jac gave equal counts on 98 of 100 starts, a largest difference of standard errors, and differing paths on 16 of 100. The smallest first-trial-point differences for the two are and standard errors. The three solvers predicted not to be invariant are not: LM-I’s paths differ on all 100 starts (H1c held), with even its first trial points at least 10.5 standard errors apart, and MINPACK-1’s and TRF-1’s paths differ on 94 and 91 starts (H1d and H1e held, each asking for at least one start). The smallest first-trial differences of MINPACK-1 and TRF-1, and standard errors, show that on at least one start their first trial point is the same in both units, which in our reading is the undamped Gauss–Newton step that the derivation predicts to be invariant.
Process note. The set-up, checks, hypotheses and every bar were written before any script ran. A planning script then computed the values the bars rely on, at one start of the grid (, , ): there, the full paths of LM-diag and MINPACK-jac agreed between the parameterizations to and standard errors, LM-I’s first step differed by 180 standard errors, and every solver reached the reference to within in relative cost and standard errors. D1, D2, C1 and C2 were also computed there and held. H1’s bars were therefore fixed in the frozen plan only after the invariance had been seen to hold on one start, so H1 is an illustration of the derivation rather than a test, and it failed on other starts; H2 had also been seen on that start; H3 was not computed before the pilot and is a test. No bar changed after the planning script. A pilot ran the script unchanged and the first run, which is shown, repeated it with identical output. The example departs from the batch plan’s description in eight ways: (1) the truth, noise level and single noise draw are fixed as above; (2) the start grid and the moment-based amplitude and background are our choice; (3) the hand-written LM’s update, initial damping and stopping rules are ours, and MINPACK’s rules are described, not ported; (4) every SciPy setting is explicit (analytic Jacobian, tolerances , a budget of 1,000 evaluations, x_scale=1.0 for 'trf'); (5) the measures are made operational (a reference minimum computed in the script, “reach” as a final cost below 1.01 times the reference, convergence as each method’s own stopping test, path differences in standard errors on every evaluated point, the error against the truth printed once for the reference, in comparisons); (6) the invariance hypothesis is split into H1a–H1e and extended to the three SciPy solvers, H2 is restricted to converged runs that end near the reference, and H3 is scored in both parameterizations; (7) data and exact-data checks D1, D2 and C1–C5 are added; (8) the fraction of starts reaching the minimum is printed without a hypothesis.
Properties and Behavior
- Descent and two limits. Nonlinear Least Squares shows that the damped step is a descent direction and states the Gauss–Newton rates; since a rejected step is never kept, the cost of the accepted iterates decreases. With a scaling matrix, the large- limit is , a short scaled-gradient step.
- Same estimator, different paths. Changing the damping matrix, the update rule or the library changes which starts succeed and how many evaluations are spent, not the minimum reached, as H2 in the example shows to within in relative cost.
- Scale invariance is a property of the derivation. Diagonal scaling from the Jacobian removes the dependence on parameter units in the derivation; in the example the invariance checks H1a and H1b failed, with the paths of LM-diag and MINPACK-jac differing between the parameterizations on 23 and 16 of 100 starts.
- Ends other than the minimum. Converged runs can stop at points of higher cost; in the example every solver did so from some starts (the “else” column). Comparing the final cost with is a cheap check when the noise level is known.
Limitations
- Local. LM refines a good start; it does not find one. Vision pipelines start it from a linear method, as camera calibration does with Zhang’s closed form before the refinement that the Zhang paper describes as Minpack’s Levenberg–Marquardt.
- Squared loss. Outliers pull the estimate; robust losses need reweighting or a solver that supports them (SciPy’s
'trf', Ceres’ loss functions), and robust estimation treats the estimators. - Unbounded parameters. The basic method has no bounds; widths, focal lengths or variances that must stay positive need a reparameterization or a bounded solver.
- Parameters that are not vectors. Rotations need a local parametrization of the update, as pose graph optimization and bundle adjustment describe.
- Dense linear algebra. A dense factorization of costs per iteration, which rules out large problems without exploiting sparsity.
- Tuning knobs. The initial damping, the update rule and the tolerances are heuristics; libraries differ in all three, so the same problem can take very different numbers of evaluations, as the
x_scale=1.0rows of the example show.
Variants
- Powell’s dog leg. A trust-region method that combines the Gauss–Newton step and the steepest-descent (Cauchy) step instead of solving for ; Ceres offers it with traditional and subspace variants, and SciPy’s
'dogbox'applies it within rectangular trust regions. - Trust-region reflective. SciPy’s default
'trf'solves trust-region subproblems that respect bounds, with an exact solver similar to MINPACK’s for dense Jacobians, according to its documentation; incommon.pyits radius is set to a quarter of the step length when the ratio is below 0.25 and doubled when the ratio exceeds 0.75 and the step reached the boundary. - Inexact-step LM. The damped system is solved approximately by an iterative method such as conjugate gradients, as in Ceres when an iterative linear solver is chosen.
- Sparse and structured LM. Bundle adjustment eliminates the points with the Schur complement before each damped solve; libraries such as SBA, Ceres and g2o implement this, as bundle adjustment describes, and reprojection error gives the cost they minimize.
- Robust LM. A robust loss is handled by rescaling residuals and Jacobian rows so that the gradient and Gauss–Newton Hessian match those of the robust cost, as SciPy’s
least_squaresdocumentation describes for its non-'lm'methods; Nonlinear Least Squares explains the reweighting.
Related
- Bundle Adjustment
The joint nonlinear least-squares refinement of all camera parameters and 3D points in a reconstruction, made practical by the sparse structure of the problem and the Schur complement.
- Camera Calibration
Estimating a camera's intrinsic parameters, lens distortion, and pose from images of a target with known geometry, usually by a closed-form initialization followed by minimizing reprojection error.
- Reprojection Error
The image distance, in pixels, between where a point was measured and where the estimated cameras and 3D structure say it should appear; the standard cost minimized in calibration, triangulation, pose estimation, and bundle adjustment.
- Pose Graph Optimization
Estimating a trajectory of poses from noisy relative-pose measurements by nonlinear least squares on SE(2), SE(3) or Sim(3), with the initialization, robust back-ends and certifiable solvers that decide whether the optimizer finds the right answer.
- Gradient Descent
An iterative method that minimizes a differentiable function by repeatedly stepping in the direction of the negative gradient, the basic optimizer behind variational image models and neural network training.
- Robust Estimation
Fitting a model when some measurements are gross errors, with M-estimators such as Huber's and Tukey's biweight solved by iteratively reweighted least squares, a robust scale such as the MAD, high-breakdown starts such as least median of squares, and the trade-off between efficiency and breakdown.
References
- Levenberg, K. (1944). A Method for the Solution of Certain Non-Linear Problems in Least Squares. Quarterly of Applied Mathematics, 2(2), 164–168 (not read).
- Marquardt, D. W. (1963). An Algorithm for Least-Squares Estimation of Nonlinear Parameters. Journal of the Society for Industrial and Applied Mathematics, 11(2), 431–441 (not read).
- Moré, J. J. (1978). The Levenberg–Marquardt Algorithm: Implementation and Theory. In G. A. Watson (ed.), Numerical Analysis, Lecture Notes in Mathematics 630, 105–116. Springer (not read; SciPy 1.16.3 dates it 1977).
- Moré, J. J., Garbow, B. S. & Hillstrom, K. E. (1980). User Guide for MINPACK-1. Argonne National Laboratory Report ANL-80-74 (not read).
- Madsen, K., Nielsen, H. B. & Tingleff, O. (2004). Methods for Non-Linear Least Squares Problems (2nd ed.). Lecture notes, Informatics and Mathematical Modelling, Technical University of Denmark (not read).
- SciPy developers (2025). scipy.optimize.least_squares: scipy/optimize/_lsq/least_squares.py, _lsq/trf.py and _lsq/common.py, SciPy v1.16.3 source code (BSD-3-Clause; read in the installed copy).
- SciPy developers (2024). C translation of MINPACK's LMDER and LMPAR: scipy/optimize/__minpack.c, SciPy v1.16.3 source code (read, not ported).
- Ceres Solver documentation (version 2.2.0): Solving Non-linear Least Squares (docs/source/nnls_solving.rst). Agarwal, S., Mierle, K. & The Ceres Solver Team.
- Ceres Solver 2.2.0 source code: internal/ceres/levenberg_marquardt_strategy.cc (BSD-3-Clause). Agarwal, S., Mierle, K. & The Ceres Solver Team.