1+ import inspect
12from typing import Optional
23
34import numpy as np
1516
1617class AbstractMultiStartGradient (AbstractMLE ):
1718
18- # Name of the optax update rule, resolved lazily via ``getattr(optax, ...)``
19- # so that ``optax`` is only imported when a fit is actually run (it is a
20- # JAX-only optional dependency). Subclasses set this + a sensible default
21- # learning rate for the rule.
19+ # Name of the optax update rule, resolved lazily at fit time from ``optax``
20+ # then ``optax.contrib`` (so ``optax`` is only imported when a fit is
21+ # actually run — it is a JAX-only optional dependency). Subclasses set this
22+ # and a default learning rate for the rule; a ``_default_learning_rate`` of
23+ # ``None`` means the rule is learning-rate-free (e.g. Prodigy) and is built
24+ # from its own default with no learning rate supplied.
2225 optax_method = None
2326 _default_learning_rate = None
2427
@@ -31,6 +34,7 @@ def __init__(
3134 n_steps : int = 300 ,
3235 learning_rate : Optional [float ] = None ,
3336 batch_size : Optional [int ] = None ,
37+ max_consecutive_nan : int = 8 ,
3438 start_lower_limit : float = 0.15 ,
3539 start_upper_limit : float = 0.85 ,
3640 initializer : Optional [AbstractInitializer ] = None ,
@@ -65,7 +69,16 @@ def __init__(
6569 The number of gradient-update steps each start is run for.
6670 learning_rate
6771 The optax learning rate. If ``None``, the rule's default is used
68- (Adam / ADABelief ``1e-2``; the sign-based Lion ``1e-3``).
72+ (Adam / ADABelief ``1e-2``; the sign-based Lion ``1e-3``). The
73+ learning-rate-free rules (e.g. Prodigy) leave this ``None`` and are
74+ built from their own default, estimating their own step scale.
75+ max_consecutive_nan
76+ The per-start rejected-step budget handed to ``optax.apply_if_finite``:
77+ a non-finite gradient/update zeroes that start's step, and only after
78+ this many *consecutive* non-finite steps does the guard error out.
79+ This is the in-step guard for the measure-zero singularities (e.g.
80+ ell_comps / shear at exactly 0); it does not rescue landscapes with
81+ broad non-finite regions (that is the Phase-2 restart-on-death layer).
6982 batch_size
7083 The number of starts evaluated per vmapped ``value_and_grad`` call,
7184 via ``jax.lax.map``. ``None`` (default) evaluates all ``n_starts`` in
@@ -101,6 +114,7 @@ def __init__(
101114 self .n_starts = n_starts
102115 self .n_steps = n_steps
103116 self .batch_size = batch_size
117+ self .max_consecutive_nan = max_consecutive_nan
104118 self .learning_rate = (
105119 learning_rate if learning_rate is not None else self ._default_learning_rate
106120 )
@@ -127,6 +141,7 @@ def _fit(
127141 import jax
128142 import jax .numpy as jnp
129143 import optax
144+ import optax .contrib # noqa: F401 — makes optax.contrib rules resolvable
130145 except ImportError as e :
131146 raise ImportError (
132147 f"{ type (self ).__name__ } requires the optional `jax` and `optax` "
@@ -176,6 +191,12 @@ def _fit(
176191 def batched_value_and_grad (params ):
177192 return jax .lax .map (_value_and_grad , params , batch_size = batch_size )
178193
194+ # The optax rule (resolved from optax / optax.contrib), guarded by
195+ # apply_if_finite, with a jitted per-start (vmapped) update step. Built
196+ # once, identically, on both the fresh and resume paths so a loaded
197+ # opt_state (itself a vmapped pytree) round-trips.
198+ optimizer , step_update = self ._build_optimizer (optax = optax , jax = jax )
199+
179200 try :
180201 search_internal = self .paths .load_search_internal ()
181202
@@ -190,8 +211,6 @@ def batched_value_and_grad(params):
190211 "Resuming MultiStartGradient search (previous samples found)."
191212 )
192213
193- optimizer = getattr (optax , self .optax_method )(self .learning_rate )
194-
195214 except (FileNotFoundError , TypeError , KeyError ):
196215
197216 params = self ._broad_starts (
@@ -201,8 +220,9 @@ def batched_value_and_grad(params):
201220 jnp = jnp ,
202221 )
203222
204- optimizer = getattr (optax , self .optax_method )(self .learning_rate )
205- opt_state = optimizer .init (params )
223+ # Per-start optimizer state: one independent state per start, so
224+ # learning-rate-free rules never share a global scalar estimate.
225+ opt_state = jax .vmap (optimizer .init )(params )
206226
207227 best_params = np .asarray (params [0 ])
208228 best_fom = np .inf
@@ -234,7 +254,7 @@ def batched_value_and_grad(params):
234254
235255 fom_history .append (best_fom )
236256
237- updates , opt_state = optimizer . update (grads , opt_state , params )
257+ updates , opt_state = step_update (grads , opt_state , params , foms )
238258 params = optax .apply_updates (params , updates )
239259
240260 total_steps += iterations
@@ -261,6 +281,69 @@ def batched_value_and_grad(params):
261281
262282 return search_internal , fitness
263283
284+ def _resolve_optax_rule (self , optax ):
285+ """
286+ Resolve ``self.optax_method`` to an optax update-rule factory, looked up
287+ first in ``optax`` (the built-in Adam family) and then in
288+ ``optax.contrib`` (the learning-rate-free / experimental rules such as
289+ ``prodigy``). Raises if neither module provides it.
290+ """
291+ rule = getattr (optax , self .optax_method , None )
292+ if rule is None :
293+ rule = getattr (optax .contrib , self .optax_method , None )
294+ if rule is None :
295+ raise ValueError (
296+ f"{ type (self ).__name__ } : optax update rule "
297+ f"'{ self .optax_method } ' was not found in `optax` or "
298+ "`optax.contrib`. Check the `optax_method` name and that the "
299+ "installed optax version provides it."
300+ )
301+ return rule
302+
303+ def _build_optimizer (self , optax , jax ):
304+ """
305+ Build the guarded optimizer and its jitted per-start update step.
306+
307+ Per-start ``jax.vmap`` over ``init`` / ``update`` is load-bearing: the
308+ learning-rate-free rules estimate a *global scalar* step scale from
309+ whole-tree norms (Prodigy / D-Adapt ``d``, DoG ``max_dist``, Mechanic's
310+ scale, MoMo's Polyak step). The stacked-``(n_starts, ndim)`` state that
311+ is safe for the elementwise Adam family would silently couple every
312+ start into one shared estimate, so each start carries its own state.
313+
314+ ``optax.apply_if_finite`` is the per-start in-step guard: a non-finite
315+ gradient/update zeroes that start's step rather than NaN-poisoning the
316+ whole population. MoMo-family rules additionally consume the loss each
317+ step via ``value=``; this is detected on the *unwrapped* rule, since
318+ ``apply_if_finite`` hides ``value`` behind ``**extra_args`` (it does
319+ forward it, for optax >= 0.2.5).
320+ """
321+ rule = self ._resolve_optax_rule (optax )
322+ base = rule () if self .learning_rate is None else rule (self .learning_rate )
323+
324+ needs_value = "value" in inspect .signature (base .update ).parameters
325+
326+ optimizer = optax .apply_if_finite (
327+ base , max_consecutive_errors = self .max_consecutive_nan
328+ )
329+
330+ if needs_value :
331+
332+ @jax .jit
333+ def step_update (grads , opt_state , params , values ):
334+ return jax .vmap (
335+ lambda g , s , p , v : optimizer .update (g , s , p , value = v )
336+ )(grads , opt_state , params , values )
337+
338+ else :
339+
340+ @jax .jit
341+ def step_update (grads , opt_state , params , values ):
342+ del values
343+ return jax .vmap (optimizer .update )(grads , opt_state , params )
344+
345+ return optimizer , step_update
346+
264347 def _broad_starts (self , model , fitness , batched_value_and_grad , jnp ):
265348 """
266349 Draw ``n_starts`` broad starting points in the unit cube, map them to
@@ -345,6 +428,7 @@ def samples_via_internal_from(
345428 "total_steps" : total_steps ,
346429 "optax_method" : self .optax_method ,
347430 "learning_rate" : self .learning_rate ,
431+ "max_consecutive_nan" : self .max_consecutive_nan ,
348432 "time" : self .timer .time if self .timer else None ,
349433 }
350434
@@ -397,3 +481,22 @@ class MultiStartLion(AbstractMultiStartGradient):
397481
398482 optax_method = "lion"
399483 _default_learning_rate = 1.0e-3
484+
485+
486+ class MultiStartProdigy (AbstractMultiStartGradient ):
487+ """
488+ Multi-start gradient MAP search using the learning-rate-free Prodigy update
489+ rule (``optax.contrib.prodigy``).
490+
491+ Prodigy estimates its own step scale, so it takes **no** learning rate
492+ (``_default_learning_rate = None``, resolved from ``optax.contrib``). On the
493+ parametric MGE cell it is bit-identical to hand-tuned Adam (best log
494+ posterior +31787.84, same winning start) with the ``learning_rate``
495+ hyperparameter deleted at zero cost — the headline learning-rate-free result
496+ from the #101 experiment. Its global distance estimate ``d`` is per-start
497+ (the base class vmaps optimizer state over starts), so starts do not share
498+ one estimate.
499+ """
500+
501+ optax_method = "prodigy"
502+ _default_learning_rate = None
0 commit comments