Skip to content

Commit d48604d

Browse files
Jammy2211claude
authored andcommitted
feat(multi-start): restart-on-death (resurrection) for gradient MAP search
Phase 2 of the multi-start gradient v2 promotion (autolens_workspace_developer#101), building on the per-start vmapped optimizer state from Phase 1 (#1398). Add a resurrect=False knob to af.AbstractMultiStartGradient. When on, any start whose objective goes non-finite is redrawn each step (fresh params from the start band + its per-start optimizer state reinitialised) via a jnp.where mask over the vmapped state pytree, leaving alive starts untouched. Keeps the population alive on likelihoods with broad non-finite regions (pixelized sources), where apply_if_finite alone latches a start at the cliff edge. Default off leaves the MGE-cell behaviour unchanged. Tracks an n_resurrections diagnostic through search_internal (resume-safe) and samples_info. Numpy-only unit tests; JAX validation follows in autofit_workspace_test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bc48c87 commit d48604d

2 files changed

Lines changed: 120 additions & 5 deletions

File tree

autofit/non_linear/search/mle/multi_start_gradient/search.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def __init__(
3737
max_consecutive_nan: int = 8,
3838
start_lower_limit: float = 0.15,
3939
start_upper_limit: float = 0.85,
40+
resurrect: bool = False,
4041
initializer: Optional[AbstractInitializer] = None,
4142
iterations_per_full_update: int = None,
4243
iterations_per_quick_update: int = None,
@@ -97,6 +98,20 @@ def __init__(
9798
The unit-cube bounds broad starts are drawn uniformly from. The
9899
interior default ``(0.15, 0.85)`` avoids the prior edges where many
99100
transforms (e.g. ``arctan2`` / ``sqrt`` at exactly 0) are singular.
101+
resurrect
102+
Restart-on-death. When ``True``, any start whose objective goes
103+
non-finite is redrawn each step (fresh params from the start band +
104+
its per-start optimizer state reinitialised), leaving alive starts
105+
untouched. Default ``False`` — the parametric (MGE-class) cell has
106+
only the measure-zero singularity, so the ``apply_if_finite`` guard
107+
suffices and behaviour/results are unchanged. Turn it on for
108+
likelihoods with broad non-finite regions (pixelized sources), where
109+
every trajectory otherwise walks into a wall within ~25–50 steps and
110+
``apply_if_finite`` alone latches the start *at* the cliff edge;
111+
resurrection keeps the population alive and makes the landscape
112+
searchable at all. (Even so, on such landscapes a nested sampler
113+
still wins decisively — resurrection makes gradient MAP *viable*
114+
there, not competitive.)
100115
"""
101116

102117
super().__init__(
@@ -120,6 +135,7 @@ def __init__(
120135
)
121136
self.start_lower_limit = start_lower_limit
122137
self.start_upper_limit = start_upper_limit
138+
self.resurrect = resurrect
123139

124140
self.logger.debug(f"Creating {self.optax_method} MultiStartGradient Search")
125141

@@ -206,6 +222,7 @@ def batched_value_and_grad(params):
206222
best_fom = float(search_internal["best_fom"])
207223
fom_history = list(search_internal["fom_history"])
208224
total_steps = int(search_internal["total_steps"])
225+
n_resurrections = int(search_internal.get("n_resurrections", 0))
209226

210227
self.logger.info(
211228
"Resuming MultiStartGradient search (previous samples found)."
@@ -228,12 +245,17 @@ def batched_value_and_grad(params):
228245
best_fom = np.inf
229246
fom_history = []
230247
total_steps = 0
248+
n_resurrections = 0
231249

232250
self.logger.info(
233251
f"Starting new {self.optax_method} MultiStartGradient search "
234252
f"({self.n_starts} starts, no previous samples found)."
235253
)
236254

255+
# Deterministic RNG for redrawing dead starts (only used when
256+
# ``resurrect`` is on); seeded independently of the broad-start draw.
257+
resurrect_rng = np.random.default_rng(1)
258+
237259
while total_steps < self.n_steps:
238260

239261
steps_remaining = self.n_steps - total_steps
@@ -244,16 +266,36 @@ def batched_value_and_grad(params):
244266
for _ in range(iterations):
245267
foms, grads = batched_value_and_grad(params)
246268

247-
foms_np = np.where(
248-
np.isfinite(np.asarray(foms)), np.asarray(foms), np.inf
249-
)
269+
alive = np.isfinite(np.asarray(foms))
270+
foms_np = np.where(alive, np.asarray(foms), np.inf)
250271
best_index = int(np.argmin(foms_np))
251272
if foms_np[best_index] < best_fom:
252273
best_fom = float(foms_np[best_index])
253274
best_params = np.asarray(params[best_index])
254275

255276
fom_history.append(best_fom)
256277

278+
# Restart-on-death: redraw any start whose objective went
279+
# non-finite (fresh params + reinitialised per-start optimizer
280+
# state), leaving alive starts untouched. best_* is captured
281+
# above, from the pre-redraw alive population. The (old,
282+
# non-finite) grads of a dead start are zeroed for one step by
283+
# apply_if_finite on its fresh state, so a redrawn start simply
284+
# waits a step before descending.
285+
if self.resurrect and not alive.all():
286+
dead_idx = np.flatnonzero(~alive)
287+
n_resurrections += int(dead_idx.size)
288+
params, opt_state = self._reinit_dead_starts(
289+
params=params,
290+
opt_state=opt_state,
291+
dead_idx=dead_idx,
292+
model=model,
293+
optimizer=optimizer,
294+
jax=jax,
295+
jnp=jnp,
296+
rng=resurrect_rng,
297+
)
298+
257299
updates, opt_state = step_update(grads, opt_state, params, foms)
258300
params = optax.apply_updates(params, updates)
259301

@@ -266,6 +308,7 @@ def batched_value_and_grad(params):
266308
"best_fom": best_fom,
267309
"fom_history": np.asarray(fom_history),
268310
"total_steps": total_steps,
311+
"n_resurrections": n_resurrections,
269312
}
270313
self.paths.save_search_internal(obj=search_internal)
271314

@@ -344,6 +387,46 @@ def step_update(grads, opt_state, params, values):
344387

345388
return optimizer, step_update
346389

390+
def _reinit_dead_starts(
391+
self, params, opt_state, dead_idx, model, optimizer, jax, jnp, rng
392+
):
393+
"""
394+
Redraw the dead starts (``dead_idx``) and reinitialise their per-start
395+
optimizer state, leaving the alive starts untouched — the restart-on-
396+
death recovery step.
397+
398+
Each dead row's params are redrawn uniformly from the start band and
399+
mapped to physical parameters; a fresh vmapped optimizer state is built
400+
and merged into the live state pytree with a boolean mask (``jnp.where``
401+
per leaf). ``np.asarray`` of a JAX array is read-only, so the redraw
402+
happens in an ``np.array`` copy.
403+
"""
404+
n = params.shape[0]
405+
406+
params_np = np.array(params) # writable copy (jax arrays are read-only)
407+
for k in dead_idx:
408+
unit_vector = rng.uniform(
409+
self.start_lower_limit, self.start_upper_limit, size=model.prior_count
410+
)
411+
params_np[k] = np.asarray(
412+
model.vector_from_unit_vector(unit_vector=list(unit_vector), xp=jnp)
413+
)
414+
params = jnp.asarray(params_np)
415+
416+
fresh_state = jax.vmap(optimizer.init)(params)
417+
418+
mask = np.zeros(n, dtype=bool)
419+
mask[dead_idx] = True
420+
mask_j = jnp.asarray(mask)
421+
422+
def merge(old, fresh):
423+
m = mask_j.reshape((n,) + (1,) * (fresh.ndim - 1))
424+
return jnp.where(m, fresh, old)
425+
426+
opt_state = jax.tree.map(merge, opt_state, fresh_state)
427+
428+
return params, opt_state
429+
347430
def _broad_starts(self, model, fitness, batched_value_and_grad, jnp):
348431
"""
349432
Draw ``n_starts`` broad starting points in the unit cube, map them to
@@ -429,6 +512,8 @@ def samples_via_internal_from(
429512
"optax_method": self.optax_method,
430513
"learning_rate": self.learning_rate,
431514
"max_consecutive_nan": self.max_consecutive_nan,
515+
"resurrect": self.resurrect,
516+
"n_resurrections": int(search_internal.get("n_resurrections", 0)),
432517
"time": self.timer.time if self.timer else None,
433518
}
434519

test_autofit/non_linear/search/mle/test_multi_start_gradient.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ def test__per_rule_defaults():
6464
assert default.batch_size is None
6565
# apply_if_finite per-start rejected-step budget (the in-step NaN guard).
6666
assert default.max_consecutive_nan == 8
67+
# restart-on-death is opt-in — default off keeps the MGE-cell behaviour.
68+
assert default.resurrect is False
69+
70+
71+
def test__resurrect_defaults_off_and_is_a_carried_knob():
72+
"""``resurrect`` (restart-on-death) is a shared base-class knob, off by
73+
default and dict-serialised so a resumed search keeps it. The redraw /
74+
per-start state reinit itself is JAX and is validated in
75+
autofit_workspace_test (the library suite is NumPy-only)."""
76+
for cls in (
77+
af.MultiStartAdam,
78+
af.MultiStartADABelief,
79+
af.MultiStartLion,
80+
af.MultiStartProdigy,
81+
):
82+
assert cls().resurrect is False
83+
assert cls(resurrect=True).resurrect is True
6784

6885

6986
def test__max_consecutive_nan_is_a_carried_knob():
@@ -119,7 +136,16 @@ def test__dict_round_trip__prodigy_is_learning_rate_free():
119136
assert restored.n_starts == 5
120137
assert restored.optax_method == "prodigy"
121138
assert restored.learning_rate is None
122-
assert restored.max_consecutive_nan == 4
139+
140+
141+
def test__dict_round_trip__resurrect():
142+
# The restart-on-death flag must survive serialisation so a resumed search
143+
# keeps redrawing dead starts.
144+
restored = from_dict(to_dict(af.MultiStartAdam(resurrect=True, n_starts=6)))
145+
146+
assert isinstance(restored, af.MultiStartAdam)
147+
assert restored.resurrect is True
148+
assert restored.n_starts == 6
123149

124150

125151
def test__samples_via_internal_from():
@@ -141,9 +167,10 @@ def test__samples_via_internal_from():
141167
"best_fom": best_fom,
142168
"fom_history": np.asarray([-4.0, -8.0, best_fom]),
143169
"total_steps": 42,
170+
"n_resurrections": 7,
144171
}
145172

146-
search = af.MultiStartAdam(n_starts=2, n_steps=42)
173+
search = af.MultiStartAdam(n_starts=2, n_steps=42, resurrect=True)
147174
samples = search.samples_via_internal_from(
148175
model=model, search_internal=search_internal
149176
)
@@ -169,3 +196,6 @@ def test__samples_via_internal_from():
169196
assert samples.samples_info["optax_method"] == "adam"
170197
assert samples.samples_info["n_starts"] == 2
171198
assert samples.samples_info["total_steps"] == 42
199+
# restart-on-death diagnostics flow through to samples_info.
200+
assert samples.samples_info["resurrect"] is True
201+
assert samples.samples_info["n_resurrections"] == 7

0 commit comments

Comments
 (0)