@@ -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
0 commit comments