Skip to content

Commit 63cd4e2

Browse files
Jammy2211Jammy2211claude
authored
feat(search): add multi-start gradient MAP searches (Adam/ADABelief/Lion) (#1370)
Promote the benchmark-winning multi-start first-order gradient MAP optimizer to first-class PyAutoFit searches. Adds AbstractMultiStartGradient(AbstractMLE) and the concrete MultiStartAdam / MultiStartADABelief / MultiStartLion rules, running N broad multi-starts vmapped over the af.Fitness seam with a fixed self-normalised optax update per start, returning best-basin MAP + per-start diagnostics through the standard samples/result contract. optax added to the jax extra. Co-authored-by: Jammy2211 <JNightingale2211@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4724e20 commit 63cd4e2

6 files changed

Lines changed: 482 additions & 1 deletion

File tree

autofit/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@
8888
from .non_linear.search.mle.drawer.search import Drawer
8989
from .non_linear.search.mle.bfgs.search import BFGS
9090
from .non_linear.search.mle.bfgs.search import LBFGS
91+
from .non_linear.search.mle.multi_start_gradient.search import MultiStartAdam
92+
from .non_linear.search.mle.multi_start_gradient.search import MultiStartADABelief
93+
from .non_linear.search.mle.multi_start_gradient.search import MultiStartLion
9194
from .non_linear.paths.abstract import AbstractPaths
9295
from .non_linear.paths import DirectoryPaths
9396
from .non_linear.paths import DatabasePaths
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from autofit.non_linear.search.mle.multi_start_gradient.search import (
2+
AbstractMultiStartGradient,
3+
MultiStartAdam,
4+
MultiStartADABelief,
5+
MultiStartLion,
6+
)
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
from typing import Optional
2+
3+
import numpy as np
4+
5+
from autofit.database.sqlalchemy_ import sa
6+
7+
from autofit.mapper.prior_model.abstract import AbstractPriorModel
8+
from autofit.non_linear.search.mle.abstract_mle import AbstractMLE
9+
from autofit.non_linear.analysis import Analysis
10+
from autofit.non_linear.fitness import Fitness
11+
from autofit.non_linear.initializer import AbstractInitializer
12+
from autofit.non_linear.samples.sample import Sample
13+
from autofit.non_linear.samples.samples import Samples
14+
15+
16+
class AbstractMultiStartGradient(AbstractMLE):
17+
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.
22+
optax_method = None
23+
_default_learning_rate = None
24+
25+
def __init__(
26+
self,
27+
name: Optional[str] = None,
28+
path_prefix: Optional[str] = None,
29+
unique_tag: Optional[str] = None,
30+
n_starts: int = 48,
31+
n_steps: int = 300,
32+
learning_rate: Optional[float] = None,
33+
start_lower_limit: float = 0.15,
34+
start_upper_limit: float = 0.85,
35+
initializer: Optional[AbstractInitializer] = None,
36+
iterations_per_full_update: int = None,
37+
iterations_per_quick_update: int = None,
38+
silence: bool = False,
39+
session: Optional[sa.orm.Session] = None,
40+
**kwargs,
41+
):
42+
"""
43+
A multi-start first-order gradient MAP optimizer (the "GIGA-Lens" recipe).
44+
45+
This search runs ``n_starts`` independent optimizations from broad,
46+
randomly drawn starting points, all in parallel via ``jax.vmap``, using a
47+
fixed self-normalised optax update rule (Adam / ADABelief / Lion) on the
48+
unconstrained (unit-cube) parameterization. A wide population of starts is
49+
what lets the method escape the many wrong basins that trap every
50+
single-start, line-search or second-order optimizer on the kinked
51+
likelihoods this promotes from (see the Phase-3 GPU MAP-optimizer
52+
benchmark). The best-basin start is returned as the maximum-log-posterior
53+
(MAP) point, with every start's final point retained as a diagnostic.
54+
55+
The method is JAX-native: it requires an ``Analysis`` whose
56+
``log_likelihood_function`` is JAX-traceable (``use_jax=True``) and the
57+
optional ``jax`` + ``optax`` dependencies.
58+
59+
Parameters
60+
----------
61+
n_starts
62+
The number of independent broad starts run in parallel (vmapped).
63+
n_steps
64+
The number of gradient-update steps each start is run for.
65+
learning_rate
66+
The optax learning rate. If ``None``, the rule's default is used
67+
(Adam / ADABelief ``1e-2``; the sign-based Lion ``1e-3``).
68+
start_lower_limit, start_upper_limit
69+
The unit-cube bounds broad starts are drawn uniformly from. The
70+
interior default ``(0.15, 0.85)`` avoids the prior edges where many
71+
transforms (e.g. ``arctan2`` / ``sqrt`` at exactly 0) are singular.
72+
"""
73+
74+
super().__init__(
75+
name=name,
76+
path_prefix=path_prefix,
77+
unique_tag=unique_tag,
78+
initializer=initializer,
79+
iterations_per_quick_update=iterations_per_quick_update,
80+
iterations_per_full_update=iterations_per_full_update,
81+
silence=silence,
82+
session=session,
83+
**kwargs,
84+
)
85+
86+
self.n_starts = n_starts
87+
self.n_steps = n_steps
88+
self.learning_rate = (
89+
learning_rate if learning_rate is not None else self._default_learning_rate
90+
)
91+
self.start_lower_limit = start_lower_limit
92+
self.start_upper_limit = start_upper_limit
93+
94+
self.logger.debug(f"Creating {self.optax_method} MultiStartGradient Search")
95+
96+
def _fit(
97+
self,
98+
model: AbstractPriorModel,
99+
analysis: Analysis,
100+
):
101+
"""
102+
Fit a model by running ``n_starts`` broad optax optimizations in parallel
103+
(vmapped) and returning the best-basin (maximum-log-posterior) start.
104+
105+
The objective minimized is ``Fitness.call`` with
106+
``fom_is_log_likelihood=False`` and ``convert_to_chi_squared=True``, which
107+
returns ``-2 * log_posterior``; invalid / NaN models map to ``+inf`` so
108+
they are never selected as the best basin.
109+
"""
110+
try:
111+
import jax
112+
import jax.numpy as jnp
113+
import optax
114+
except ImportError as e:
115+
raise ImportError(
116+
f"{type(self).__name__} requires the optional `jax` and `optax` "
117+
"dependencies. Install them with `pip install autofit[jax] optax`."
118+
) from e
119+
120+
if not getattr(analysis, "_use_jax", False):
121+
raise ValueError(
122+
f"{type(self).__name__} is a JAX-native gradient search and "
123+
"requires a JAX-traceable Analysis (e.g. `AnalysisImaging(..., "
124+
"use_jax=True)`). The supplied analysis is not running on the JAX "
125+
"backend."
126+
)
127+
128+
fitness = Fitness(
129+
model=model,
130+
analysis=analysis,
131+
paths=self.paths,
132+
fom_is_log_likelihood=False,
133+
resample_figure_of_merit=-np.inf,
134+
convert_to_chi_squared=True,
135+
)
136+
137+
# -2 * log_posterior, to MINIMIZE. value_and_grad batched over starts.
138+
batched_value_and_grad = jax.jit(jax.vmap(jax.value_and_grad(fitness.call)))
139+
140+
try:
141+
search_internal = self.paths.load_search_internal()
142+
143+
params = jnp.asarray(search_internal["params"])
144+
opt_state = optax.tree_utils.tree_get(search_internal, "opt_state")
145+
best_params = np.asarray(search_internal["best_params"])
146+
best_fom = float(search_internal["best_fom"])
147+
fom_history = list(search_internal["fom_history"])
148+
total_steps = int(search_internal["total_steps"])
149+
150+
self.logger.info(
151+
"Resuming MultiStartGradient search (previous samples found)."
152+
)
153+
154+
optimizer = getattr(optax, self.optax_method)(self.learning_rate)
155+
156+
except (FileNotFoundError, TypeError, KeyError):
157+
158+
params = self._broad_starts(
159+
model=model,
160+
fitness=fitness,
161+
batched_value_and_grad=batched_value_and_grad,
162+
jnp=jnp,
163+
)
164+
165+
optimizer = getattr(optax, self.optax_method)(self.learning_rate)
166+
opt_state = optimizer.init(params)
167+
168+
best_params = np.asarray(params[0])
169+
best_fom = np.inf
170+
fom_history = []
171+
total_steps = 0
172+
173+
self.logger.info(
174+
f"Starting new {self.optax_method} MultiStartGradient search "
175+
f"({self.n_starts} starts, no previous samples found)."
176+
)
177+
178+
while total_steps < self.n_steps:
179+
180+
steps_remaining = self.n_steps - total_steps
181+
iterations = min(
182+
self.iterations_per_full_update or self.n_steps, steps_remaining
183+
)
184+
185+
for _ in range(iterations):
186+
foms, grads = batched_value_and_grad(params)
187+
188+
foms_np = np.where(
189+
np.isfinite(np.asarray(foms)), np.asarray(foms), np.inf
190+
)
191+
best_index = int(np.argmin(foms_np))
192+
if foms_np[best_index] < best_fom:
193+
best_fom = float(foms_np[best_index])
194+
best_params = np.asarray(params[best_index])
195+
196+
fom_history.append(best_fom)
197+
198+
updates, opt_state = optimizer.update(grads, opt_state, params)
199+
params = optax.apply_updates(params, updates)
200+
201+
total_steps += iterations
202+
203+
search_internal = {
204+
"params": np.asarray(params),
205+
"opt_state": opt_state,
206+
"best_params": best_params,
207+
"best_fom": best_fom,
208+
"fom_history": np.asarray(fom_history),
209+
"total_steps": total_steps,
210+
}
211+
self.paths.save_search_internal(obj=search_internal)
212+
213+
self.perform_update(
214+
model=model,
215+
analysis=analysis,
216+
during_analysis=total_steps < self.n_steps,
217+
fitness=fitness,
218+
search_internal=search_internal,
219+
)
220+
221+
self.logger.info(f"{self.optax_method} MultiStartGradient sampling complete.")
222+
223+
return search_internal, fitness
224+
225+
def _broad_starts(self, model, fitness, batched_value_and_grad, jnp):
226+
"""
227+
Draw ``n_starts`` broad starting points in the unit cube, map them to
228+
physical parameters, and keep only those with a finite objective and a
229+
finite gradient (degenerate points such as ell_comps / shear at exactly 0
230+
have NaN gradients and must be filtered out).
231+
"""
232+
rng = np.random.default_rng(0)
233+
234+
starts = []
235+
max_tries = self.n_starts * 30
236+
tries = 0
237+
while len(starts) < self.n_starts and tries < max_tries:
238+
tries += 1
239+
unit_vector = rng.uniform(
240+
self.start_lower_limit, self.start_upper_limit, size=model.prior_count
241+
)
242+
vector = jnp.asarray(
243+
model.vector_from_unit_vector(unit_vector=list(unit_vector), xp=jnp)
244+
)
245+
fom, grad = jax_value_and_grad_single(fitness, vector)
246+
if np.isfinite(float(fom)) and np.all(np.isfinite(np.asarray(grad))):
247+
starts.append(vector)
248+
249+
if len(starts) == 0:
250+
raise ValueError(
251+
f"{type(self).__name__} could not draw any finite-gradient starting "
252+
f"points in {tries} attempts. Check the model / analysis are "
253+
"JAX-traceable and the prior ranges are not everywhere singular."
254+
)
255+
256+
if len(starts) < self.n_starts:
257+
self.logger.warning(
258+
f"Only collected {len(starts)}/{self.n_starts} finite-gradient "
259+
f"starts (from {tries} draws)."
260+
)
261+
262+
return jnp.stack(starts)
263+
264+
def samples_via_internal_from(
265+
self, model: AbstractPriorModel, search_internal=None
266+
):
267+
"""
268+
Returns a `Samples` object from the MultiStartGradient internal results.
269+
270+
The best-basin (maximum-log-posterior) start is the first sample; every
271+
start's final point is retained as a diagnostic sample so per-start basin
272+
spread can be inspected downstream.
273+
"""
274+
if search_internal is None:
275+
search_internal = self.paths.load_search_internal()
276+
277+
best_params = np.asarray(search_internal["best_params"])
278+
per_start_params = np.asarray(search_internal["params"])
279+
total_steps = int(search_internal["total_steps"])
280+
281+
parameter_lists = [list(best_params)] + [list(p) for p in per_start_params]
282+
283+
log_prior_list = model.log_prior_list_from(parameter_lists=parameter_lists)
284+
285+
# Fitness.call returns -2 * log_posterior, so log_posterior = -0.5 * fom.
286+
best_log_posterior = -0.5 * float(search_internal["best_fom"])
287+
log_likelihood_list = [best_log_posterior - log_prior_list[0]]
288+
log_likelihood_list += [
289+
np.nan for _ in range(len(parameter_lists) - 1)
290+
]
291+
292+
weight_list = [1.0] + [0.0] * (len(parameter_lists) - 1)
293+
294+
sample_list = Sample.from_lists(
295+
model=model,
296+
parameter_lists=parameter_lists,
297+
log_likelihood_list=log_likelihood_list,
298+
log_prior_list=log_prior_list,
299+
weight_list=weight_list,
300+
)
301+
302+
samples_info = {
303+
"n_starts": self.n_starts,
304+
"n_steps": self.n_steps,
305+
"total_steps": total_steps,
306+
"optax_method": self.optax_method,
307+
"learning_rate": self.learning_rate,
308+
"time": self.timer.time if self.timer else None,
309+
}
310+
311+
return Samples(
312+
model=model,
313+
sample_list=sample_list,
314+
samples_info=samples_info,
315+
)
316+
317+
318+
def jax_value_and_grad_single(fitness, vector):
319+
"""
320+
Single-point ``value_and_grad`` of the fitness objective, used to filter
321+
broad starts down to those with a finite value and gradient before the
322+
batched loop begins.
323+
"""
324+
import jax
325+
326+
return jax.value_and_grad(fitness.call)(vector)
327+
328+
329+
class MultiStartAdam(AbstractMultiStartGradient):
330+
"""
331+
Multi-start gradient MAP search using the Adam optax update rule.
332+
333+
Adam was the certified best method in the Phase-3 GPU MAP-optimizer
334+
benchmark — no line-search or second-order optimizer beat wide multi-start
335+
Adam on the kinked (NNLS active-set) lens likelihood.
336+
"""
337+
338+
optax_method = "adam"
339+
_default_learning_rate = 1.0e-2
340+
341+
342+
class MultiStartADABelief(AbstractMultiStartGradient):
343+
"""
344+
Multi-start gradient MAP search using the ADABelief optax update rule, which
345+
tied Adam for best in the Phase-3 benchmark.
346+
"""
347+
348+
optax_method = "adabelief"
349+
_default_learning_rate = 1.0e-2
350+
351+
352+
class MultiStartLion(AbstractMultiStartGradient):
353+
"""
354+
Multi-start gradient MAP search using the Lion optax update rule. Lion is
355+
sign-based, so it wants a ~10x smaller learning rate than Adam / ADABelief.
356+
"""
357+
358+
optax_method = "lion"
359+
_default_learning_rate = 1.0e-3

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 = ["autoconf[jax]"]
69+
jax = ["autoconf[jax]", "optax"]
7070
optional = [
7171
"autofit[jax]",
7272
"astropy>=5.0",

test_autofit/non_linear/search/mle/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)