Skip to content

Commit c8acdd3

Browse files
Jammy2211claude
authored andcommitted
fix: LM damping parity + stall guards for potential-correction iterative engines
The iterative engines' Marquardt mu*diag(H) damping diverged from the reference implementation's mu*I, changing recovered signals at fixed iteration budgets (PyAutoLens#672). damping= option added to both engines (imaging default restored to identity; interferometer keeps marquardt); rejected steps below tol now return as converged and consecutive rejections are capped, ending the end-of-budget rejection storm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 280eb04 commit c8acdd3

6 files changed

Lines changed: 206 additions & 15 deletions

File tree

autolens/potential_correction/dense_util.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -374,22 +374,38 @@ def lm_hessian_and_gradient_from(
374374
return H, minus_gradient, residual, chi2, reg_s, reg_dpsi, cost
375375

376376

377-
def solve_lm_step_from(H, minus_gradient, mu, constraint_matrix=None, x=None, xp=np):
377+
def solve_lm_step_from(
378+
H, minus_gradient, mu, constraint_matrix=None, x=None, xp=np, damping="marquardt"
379+
):
378380
"""
379-
The damped LM step delta_x solving (H + mu D) dx = -g with Marquardt
380-
scaling D = diag(diag(H)) (clipped below at the mean diagonal times
381-
1e-12 so zero diagonal entries stay damped) — scale-invariant damping,
382-
required when H's magnitude varies over many orders between datasets
383-
(e.g. visibility-weighted interferometer curvatures ~1e11 vs imaging
384-
~1e4). When a ``constraint_matrix`` C is given, the equality-constrained
385-
step solves the KKT system enforcing C (x + dx) = 0.
381+
The damped LM step delta_x solving (H + mu D) dx = -g.
382+
383+
``damping="identity"`` uses D = I — the damping of the reference
384+
implementation (Cao et al. 2025): at moderate mu it barely perturbs
385+
high-curvature directions, so early steps are near full Gauss-Newton and
386+
the imaging problem converges in a few iterations from a cold start.
387+
``damping="marquardt"`` uses the scale-invariant D = diag(diag(H))
388+
(clipped below at the mean diagonal times 1e-12 so zero diagonal entries
389+
stay damped), required when H's magnitude varies over many orders between
390+
datasets (e.g. visibility-weighted interferometer curvatures ~1e11 vs
391+
imaging ~1e4); its steps are far more conservative at the same mu, so a
392+
small iteration budget under-converges relative to identity damping.
393+
When a ``constraint_matrix`` C is given, the equality-constrained step
394+
solves the KKT system enforcing C (x + dx) = 0.
386395
"""
387396
H_d = as_dense(H, xp=xp)
388397
g = xp.asarray(minus_gradient)
389398
n_x = H_d.shape[0]
390-
diag = xp.diag(H_d)
391-
diag = xp.clip(diag, 1e-12 * xp.mean(xp.abs(diag)), None)
392-
H_lm = H_d + mu * xp.diag(diag)
399+
if damping == "identity":
400+
H_lm = H_d + mu * xp.eye(n_x, dtype=H_d.dtype)
401+
elif damping == "marquardt":
402+
diag = xp.diag(H_d)
403+
diag = xp.clip(diag, 1e-12 * xp.mean(xp.abs(diag)), None)
404+
H_lm = H_d + mu * xp.diag(diag)
405+
else:
406+
raise ValueError(
407+
f"damping must be 'identity' or 'marquardt', got {damping!r}"
408+
)
393409

394410
if constraint_matrix is None:
395411
return xp.linalg.solve(H_lm, g)

autolens/potential_correction/iterative.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def __init__(
5757
preloads: Optional[dict] = None,
5858
n_iter: int = 20,
5959
tol: float = 1e-6,
60+
damping: str = "identity",
61+
max_consecutive_rejections: int = 10,
6062
verbose: bool = False,
6163
visualize_output_dir: Optional[str] = None,
6264
visualize_every_n: int = 1000000,
@@ -98,7 +100,22 @@ def __init__(
98100
n_iter
99101
The maximum number of outer LM iterations.
100102
tol
101-
The step-norm convergence tolerance.
103+
The step-norm convergence tolerance. Also applied to rejected
104+
steps: once a proposed step is smaller than ``tol``, growing the
105+
damping can only shrink it further, so the solve returns rather
106+
than rejecting its way to the mu ceiling.
107+
damping
108+
The LM damping matrix (``dense_util.solve_lm_step_from``):
109+
``"identity"`` (default) is the reference implementation's
110+
``H + mu I`` — near Gauss-Newton early steps, converging the
111+
imaging problem in a few iterations from a cold start;
112+
``"marquardt"`` is the scale-invariant ``H + mu diag(H)``, whose
113+
conservative steps need a much larger iteration budget.
114+
max_consecutive_rejections
115+
Stop after this many consecutive rejected trial steps (each costs
116+
a full Jacobian rebuild); at a cost minimum no decreasing step
117+
exists and unbounded rejection wastes the runtime driving mu to
118+
its ceiling.
102119
verbose
103120
Whether to log per-iteration costs.
104121
visualize_output_dir
@@ -115,6 +132,8 @@ def __init__(
115132
self.src_image_mesh = src_image_mesh
116133
self.n_iter = int(n_iter)
117134
self.tol = float(tol)
135+
self.damping = str(damping)
136+
self.max_consecutive_rejections = int(max_consecutive_rejections)
118137
self.verbose = bool(verbose)
119138
self.visualize_output_dir = visualize_output_dir
120139
self.visualize_every_n = int(visualize_every_n)
@@ -486,12 +505,14 @@ def solve_joint_optimization(self, xp=np, x0=None, gauge_project_x0=False):
486505
)
487506

488507
step_accepted = False
508+
consecutive_rejections = 0
489509
while not step_accepted:
490510
delta_x = None
491511
try:
492512
delta_x = dense_util.solve_lm_step_from(
493513
H, minus_gradient, mu,
494514
constraint_matrix=constraint_matrix, x=x, xp=xp,
515+
damping=self.damping,
495516
)
496517
if np.any(np.isnan(np.asarray(delta_x))):
497518
delta_x = None
@@ -530,6 +551,30 @@ def solve_joint_optimization(self, xp=np, x0=None, gauge_project_x0=False):
530551
self.dpsi_opt = np.asarray(x[n_s:])
531552
return self.s_opt, self.dpsi_opt
532553
else:
554+
# rejected step below the step tolerance: growing mu
555+
# only shrinks it further — the state is converged
556+
# (at a cost minimum no decreasing step exists), so
557+
# return instead of rejecting to the mu ceiling.
558+
if float(xp.linalg.norm(delta_x)) < self.tol:
559+
if self.verbose:
560+
logger.info(
561+
"Converged at iteration %d (rejected step "
562+
"below tolerance).",
563+
i,
564+
)
565+
self.s_opt = np.asarray(x[:n_s])
566+
self.dpsi_opt = np.asarray(x[n_s:])
567+
return self.s_opt, self.dpsi_opt
568+
consecutive_rejections += 1
569+
if consecutive_rejections >= self.max_consecutive_rejections:
570+
logger.warning(
571+
"%d consecutive rejected LM steps (each a full "
572+
"Jacobian rebuild); stopping at the current state.",
573+
consecutive_rejections,
574+
)
575+
self.s_opt = np.asarray(x[:n_s])
576+
self.dpsi_opt = np.asarray(x[n_s:])
577+
return self.s_opt, self.dpsi_opt
533578
mu *= 5.0
534579
if mu > 1e15:
535580
logger.warning(
@@ -539,6 +584,16 @@ def solve_joint_optimization(self, xp=np, x0=None, gauge_project_x0=False):
539584
self.dpsi_opt = np.asarray(x[n_s:])
540585
return self.s_opt, self.dpsi_opt
541586
else:
587+
consecutive_rejections += 1
588+
if consecutive_rejections >= self.max_consecutive_rejections:
589+
logger.warning(
590+
"%d consecutive failed LM solves; stopping at the "
591+
"current state.",
592+
consecutive_rejections,
593+
)
594+
self.s_opt = np.asarray(x[:n_s])
595+
self.dpsi_opt = np.asarray(x[n_s:])
596+
return self.s_opt, self.dpsi_opt
542597
mu *= 5.0
543598
if mu > 1e15:
544599
logger.warning("LM solver failed repeatedly; stopping.")

autolens/potential_correction/iterative_interferometer.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def __init__(
7070
preloads: Optional[dict] = None,
7171
n_iter: int = 20,
7272
tol: float = 1e-6,
73+
damping: str = "marquardt",
74+
max_consecutive_rejections: int = 10,
7375
reg_optimize_every: Optional[int] = None,
7476
reg_optimize_grid: int = 5,
7577
verbose: bool = False,
@@ -119,7 +121,14 @@ def __init__(
119121
n_iter
120122
The maximum number of outer LM iterations.
121123
tol
122-
The step-norm convergence tolerance.
124+
The step-norm convergence tolerance; also applied to rejected
125+
steps (a sub-tolerance rejected step means converged).
126+
damping
127+
LM damping matrix (see ``IterFitDpsiSrcImaging``): default
128+
``"marquardt"`` here — visibility-weighted curvatures (~1e11)
129+
need the scale-invariant form.
130+
max_consecutive_rejections
131+
Stop after this many consecutive rejected trial steps.
123132
reg_optimize_every
124133
When set, every N accepted outer iterations (and once at the
125134
start) the regularization strength multipliers (a_src, a_dpsi)
@@ -144,6 +153,8 @@ def __init__(
144153
self.dpsi_mask = dpsi_mask
145154
self.n_iter = int(n_iter)
146155
self.tol = float(tol)
156+
self.damping = str(damping)
157+
self.max_consecutive_rejections = int(max_consecutive_rejections)
147158
self.reg_optimize_every = reg_optimize_every
148159
self.reg_optimize_grid = int(reg_optimize_grid)
149160
self.reg_scales = (1.0, 1.0)
@@ -565,12 +576,14 @@ def solve_joint_optimization(self, x0=None):
565576
)
566577

567578
step_accepted = False
579+
consecutive_rejections = 0
568580
while not step_accepted:
569581
delta_x = None
570582
try:
571583
delta_x = dense_util.solve_lm_step_from(
572584
H, minus_gradient, mu,
573585
constraint_matrix=constraint_matrix, x=x,
586+
damping=self.damping,
574587
)
575588
if np.any(np.isnan(np.asarray(delta_x))):
576589
delta_x = None
@@ -622,13 +635,39 @@ def solve_joint_optimization(self, x0=None):
622635
self._final_state = (F, D, A, R)
623636
return self.s_opt, self.dpsi_opt
624637
else:
638+
# rejected step below the step tolerance: growing mu
639+
# only shrinks it further — the state is converged.
640+
if float(np.linalg.norm(delta_x)) < self.tol:
641+
if self.verbose:
642+
logger.info(
643+
"Converged at iteration %d (rejected step "
644+
"below tolerance).",
645+
i,
646+
)
647+
break
648+
consecutive_rejections += 1
649+
if consecutive_rejections >= self.max_consecutive_rejections:
650+
logger.warning(
651+
"%d consecutive rejected LM steps; stopping at "
652+
"the current state.",
653+
consecutive_rejections,
654+
)
655+
break
625656
mu *= 5.0
626657
if mu > 1e15:
627658
logger.warning(
628659
"LM damping parameter exceeded 1e15; stopping."
629660
)
630661
break
631662
else:
663+
consecutive_rejections += 1
664+
if consecutive_rejections >= self.max_consecutive_rejections:
665+
logger.warning(
666+
"%d consecutive failed LM solves; stopping at the "
667+
"current state.",
668+
consecutive_rejections,
669+
)
670+
break
632671
mu *= 5.0
633672
if mu > 1e15:
634673
logger.warning("LM solver failed repeatedly; stopping.")

test_autolens/potential_correction/test_dense_util.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,38 @@ def test__solve_lm_step_from__unconstrained_and_constrained():
233233
assert float((C @ (x + step_c))[0]) == pytest.approx(0.0, abs=1.0e-8)
234234

235235

236+
def test__solve_lm_step_from__identity_damping():
237+
data, noise, mapping, src_reg, dpsi_reg = joint_problem()
238+
n_src = src_reg.shape[0]
239+
inv_var = 1.0 / noise**2
240+
x = np.zeros(mapping.shape[1])
241+
242+
H, minus_gradient, *_ = dense_util.lm_hessian_and_gradient_from(
243+
data, inv_var, x, mapping[:, :n_src], mapping[:, n_src:], src_reg, dpsi_reg
244+
)
245+
246+
mu = 0.7
247+
step = dense_util.solve_lm_step_from(H, minus_gradient, mu, damping="identity")
248+
assert (H + mu * np.eye(H.shape[0])) @ step == pytest.approx(
249+
minus_gradient, rel=1.0e-8
250+
)
251+
252+
# the two damping forms genuinely differ once diag(H) is not ~1
253+
step_m = dense_util.solve_lm_step_from(H, minus_gradient, mu, damping="marquardt")
254+
assert not np.allclose(step, step_m)
255+
256+
# constrained identity step stays on the constraint surface
257+
C = np.zeros((1, mapping.shape[1]))
258+
C[0, n_src:] = 1.0
259+
step_c = dense_util.solve_lm_step_from(
260+
H, minus_gradient, mu, constraint_matrix=C, x=x, damping="identity"
261+
)
262+
assert float((C @ (x + step_c))[0]) == pytest.approx(0.0, abs=1.0e-8)
263+
264+
with pytest.raises(ValueError):
265+
dense_util.solve_lm_step_from(H, minus_gradient, mu, damping="not-a-mode")
266+
267+
236268
def test__log_evidence_lm_from__matches_hand_computed():
237269
data, noise, mapping, src_reg, dpsi_reg = joint_problem()
238270
n_src = src_reg.shape[0]

test_autolens/potential_correction/test_iterative.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import autolens as al
66

77

8-
def iter_fit_from(masked_imaging, gauge_constraints=False, n_iter=2):
8+
def iter_fit_from(masked_imaging, gauge_constraints=False, n_iter=2, **kwargs):
99
lens = al.Galaxy(
1010
redshift=0.5,
1111
mass=al.mp.IsothermalSph(centre=(0.0, 0.0), einstein_radius=1.0),
@@ -25,6 +25,7 @@ def iter_fit_from(masked_imaging, gauge_constraints=False, n_iter=2):
2525
src_pixelization=src_pixelization,
2626
gauge_constraints=gauge_constraints,
2727
n_iter=n_iter,
28+
**kwargs,
2829
)
2930

3031

@@ -132,3 +133,40 @@ def test__log_evidence__requires_state_or_solve(masked_imaging_7x7):
132133

133134
with pytest.raises(ValueError):
134135
fit.log_evidence()
136+
137+
138+
def test__damping_marquardt__solves_finite(masked_imaging_7x7):
139+
fit = iter_fit_from(masked_imaging_7x7, damping="marquardt")
140+
141+
s_opt, dpsi_opt = fit.solve_joint_optimization()
142+
143+
assert np.isfinite(s_opt).all()
144+
assert np.isfinite(dpsi_opt).all()
145+
146+
147+
def test__warm_start_at_optimum__stall_guards_bound_the_rebuilds(
148+
masked_imaging_7x7,
149+
):
150+
# solving again from the converged optimum admits no cost-decreasing step;
151+
# the tol-on-rejected-step / consecutive-rejection guards must return after
152+
# a bounded number of Jacobian rebuilds instead of rejecting mu to 1e15
153+
# (mu grows x5 per rejection: reaching 1e15 from 1.0 takes ~22 rejections).
154+
fit0 = iter_fit_from(masked_imaging_7x7)
155+
s_opt, dpsi_opt = fit0.solve_joint_optimization()
156+
x0 = np.concatenate([s_opt, dpsi_opt])
157+
158+
fit = iter_fit_from(masked_imaging_7x7, n_iter=5, max_consecutive_rejections=3)
159+
original = fit.get_L_Js_Jdpsi
160+
calls = {"n": 0}
161+
162+
def counting(*args, **kwargs):
163+
calls["n"] += 1
164+
return original(*args, **kwargs)
165+
166+
fit.get_L_Js_Jdpsi = counting
167+
s_new, dpsi_new = fit.solve_joint_optimization(x0=x0)
168+
169+
assert np.isfinite(s_new).all()
170+
assert np.isfinite(dpsi_new).all()
171+
# init + at most (accepts + rejections-per-iteration capped at 3) trials
172+
assert calls["n"] <= 1 + 5 * 4

test_autolens/potential_correction/test_iterative_interferometer.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
)
1717

1818

19-
def iter_fit_from(dataset, gauge_constraints=False, n_iter=2):
19+
def iter_fit_from(dataset, gauge_constraints=False, n_iter=2, **kwargs):
2020
lens = al.Galaxy(
2121
redshift=0.5,
2222
mass=al.mp.IsothermalSph(centre=(0.0, 0.0), einstein_radius=1.0),
@@ -36,6 +36,7 @@ def iter_fit_from(dataset, gauge_constraints=False, n_iter=2):
3636
src_pixelization=src_pixelization,
3737
gauge_constraints=gauge_constraints,
3838
n_iter=n_iter,
39+
**kwargs,
3940
)
4041

4142

@@ -59,6 +60,16 @@ def test__solve_joint_optimization__finite_state_and_decreasing_cost(
5960
assert np.isfinite(s_opt).all()
6061
assert np.isfinite(dpsi_opt).all()
6162

63+
64+
def test__solve_joint_optimization__identity_damping_finite(interferometer_7):
65+
dataset = interferometer_7.apply_sparse_operator()
66+
fit = iter_fit_from(dataset, damping="identity", max_consecutive_rejections=3)
67+
68+
s_opt, dpsi_opt = fit.solve_joint_optimization()
69+
70+
assert np.isfinite(s_opt).all()
71+
assert np.isfinite(dpsi_opt).all()
72+
6273
# the optimized state must beat the zero starting state, whose penalized
6374
# cost is 0.5 d^H C^-1 d
6475
x = np.concatenate([s_opt, dpsi_opt])

0 commit comments

Comments
 (0)