Skip to content

Commit bc48c87

Browse files
authored
Merge pull request #1398 from PyAutoLabs/feature/multistart-contrib-vmapped-state
feat(autofit): MultiStartProdigy + optax.contrib + per-start vmapped state
2 parents 2c1835d + 19f0cdf commit bc48c87

4 files changed

Lines changed: 156 additions & 12 deletions

File tree

autofit/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
from .non_linear.search.mle.multi_start_gradient.search import MultiStartAdam
9292
from .non_linear.search.mle.multi_start_gradient.search import MultiStartADABelief
9393
from .non_linear.search.mle.multi_start_gradient.search import MultiStartLion
94+
from .non_linear.search.mle.multi_start_gradient.search import MultiStartProdigy
9495
from .non_linear.paths.abstract import AbstractPaths
9596
from .non_linear.paths import DirectoryPaths
9697
from .non_linear.paths import DatabasePaths

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

Lines changed: 113 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
from typing import Optional
23

34
import numpy as np
@@ -15,10 +16,12 @@
1516

1617
class 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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ local_scheme = "no-local-version"
6666

6767

6868
[project.optional-dependencies]
69-
jax = ["autonerves[jax]", "optax"]
69+
jax = ["autonerves[jax]", "optax>=0.2.5"]
7070
optional = [
7171
"autofit[jax]",
7272
"astropy>=5.0",

test_autofit/non_linear/search/mle/test_multi_start_gradient.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ def test__per_rule_defaults():
4747
assert af.MultiStartLion().optax_method == "lion"
4848
assert af.MultiStartLion().learning_rate == pytest.approx(1.0e-3)
4949

50+
# Prodigy is learning-rate-free: it resolves from optax.contrib and takes no
51+
# learning rate (None -> built from the rule's own default at fit time).
52+
assert af.MultiStartProdigy().optax_method == "prodigy"
53+
assert af.MultiStartProdigy().learning_rate is None
54+
assert af.MultiStartProdigy._default_learning_rate is None
55+
5056
# defaults for the shared knobs
5157
default = af.MultiStartAdam()
5258
assert default.n_starts == 48
@@ -56,6 +62,21 @@ def test__per_rule_defaults():
5662
# batch_size defaults to None = evaluate all starts in one vmapped call
5763
# (the pre-batch_size behaviour, so no regression).
5864
assert default.batch_size is None
65+
# apply_if_finite per-start rejected-step budget (the in-step NaN guard).
66+
assert default.max_consecutive_nan == 8
67+
68+
69+
def test__max_consecutive_nan_is_a_carried_knob():
70+
"""The apply_if_finite budget is a shared base-class knob, dict-serialised
71+
so a resumed search rebuilds the same guard."""
72+
for cls in (
73+
af.MultiStartAdam,
74+
af.MultiStartADABelief,
75+
af.MultiStartLion,
76+
af.MultiStartProdigy,
77+
):
78+
assert cls().max_consecutive_nan == 8
79+
assert cls(max_consecutive_nan=3).max_consecutive_nan == 3
5980

6081

6182
def test__batch_size_is_carried_to_every_rule():
@@ -66,7 +87,12 @@ def test__batch_size_is_carried_to_every_rule():
6687
and is asserted in autofit_workspace_test, since the library suite is
6788
NumPy-only.
6889
"""
69-
for cls in (af.MultiStartAdam, af.MultiStartADABelief, af.MultiStartLion):
90+
for cls in (
91+
af.MultiStartAdam,
92+
af.MultiStartADABelief,
93+
af.MultiStartLion,
94+
af.MultiStartProdigy,
95+
):
7096
assert cls().batch_size is None
7197
assert cls(batch_size=8).batch_size == 8
7298

@@ -82,6 +108,20 @@ def test__dict_round_trip():
82108
assert restored.learning_rate == pytest.approx(1.0e-3)
83109

84110

111+
def test__dict_round_trip__prodigy_is_learning_rate_free():
112+
# The learning-rate-free rule must survive serialisation with lr None so a
113+
# resumed Prodigy search rebuilds from the rule's own default, not lr=None
114+
# being coerced to a number.
115+
dictionary = to_dict(af.MultiStartProdigy(n_starts=5, max_consecutive_nan=4))
116+
restored = from_dict(dictionary)
117+
118+
assert isinstance(restored, af.MultiStartProdigy)
119+
assert restored.n_starts == 5
120+
assert restored.optax_method == "prodigy"
121+
assert restored.learning_rate is None
122+
assert restored.max_consecutive_nan == 4
123+
124+
85125
def test__samples_via_internal_from():
86126
model = af.Model(example.Gaussian)
87127

0 commit comments

Comments
 (0)