Skip to content

Commit 1513236

Browse files
authored
Merge pull request #600 from PyAutoLabs/feature/multi-shared-state-core-api
feat: imaging shared-state consumer — AnalysisImaging.shared_state_from + mesh preload reuse (phase 2/4)
2 parents 06f63fd + 2103f17 commit 1513236

4 files changed

Lines changed: 269 additions & 9 deletions

File tree

autolens/imaging/fit_imaging.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ def __init__(
4444
dataset_model : Optional[aa.DatasetModel] = None,
4545
adapt_images: Optional[ag.AdaptImages] = None,
4646
settings: aa.Settings = None,
47-
xp=np
47+
xp=np,
48+
preloads=None,
4849
):
4950
"""
5051
Fits an imaging dataset using a `Tracer` object.
@@ -83,6 +84,11 @@ def __init__(
8384
reconstructed galaxy's morphology.
8485
settings
8586
Settings controlling how an inversion is fitted for example which linear algebra formalism is used.
87+
preloads
88+
An optional `PreloadsImaging` carrying exposure-invariant quantities (the shared
89+
source-plane mesh geometry) computed once and reused by the fit instead of being
90+
rebuilt. Supplied by the multi-exposure shared-state path (see
91+
`AnalysisImaging.shared_state_from`); `None` (the default) fits as normal.
8692
"""
8793

8894
super().__init__(dataset=dataset, dataset_model=dataset_model, xp=xp)
@@ -94,6 +100,7 @@ def __init__(
94100

95101
self.adapt_images = adapt_images
96102
self.settings = settings or aa.Settings()
103+
self.preloads = preloads
97104

98105
@functools.cached_property
99106
def blurred_image(self) -> aa.Array2D:
@@ -140,6 +147,7 @@ def tracer_to_inversion(self) -> TracerToInversion:
140147
adapt_images=self.adapt_images,
141148
settings=self.settings,
142149
xp=self._xp,
150+
preloads=self.preloads,
143151
)
144152

145153
@cached_property

autolens/imaging/model/analysis.py

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515
import logging
1616

17+
import autoarray as aa
1718
import autofit as af
1819
import autogalaxy as ag
1920

@@ -39,7 +40,48 @@ class AnalysisImaging(AnalysisDataset):
3940
Visualizer = VisualizerImaging
4041
Latent = LatentLens
4142

42-
def log_likelihood_function(self, instance: af.ModelInstance) -> float:
43+
def __init__(
44+
self,
45+
dataset,
46+
positions_likelihood_list=None,
47+
adapt_images: ag.AdaptImages = None,
48+
cosmology: ag.cosmo.LensingCosmology = None,
49+
settings=None,
50+
raise_inversion_positions_likelihood_exception: bool = True,
51+
title_prefix: str = None,
52+
use_jax: bool = True,
53+
shared_preloads: bool = False,
54+
**kwargs,
55+
):
56+
"""
57+
Fits a lens model to an imaging dataset via a non-linear search (see `AnalysisDataset` for
58+
the full docstring of the shared parameters).
59+
60+
Parameters
61+
----------
62+
shared_preloads
63+
Opts this analysis into the cross-factor shared-state mechanism of a `FactorGraphModel`
64+
(see `shared_state_from`). Set this to `True` only when this analysis is one of many
65+
exposures of the same lens (e.g. multi-exposure imaging with per-exposure pixel offsets)
66+
sharing an identical lens model, so the exposure-invariant source-plane mesh geometry
67+
can be computed once and reused by every exposure. `False` by default, leaving the
68+
standard per-analysis behaviour unchanged.
69+
"""
70+
super().__init__(
71+
dataset=dataset,
72+
positions_likelihood_list=positions_likelihood_list,
73+
adapt_images=adapt_images,
74+
cosmology=cosmology,
75+
settings=settings,
76+
raise_inversion_positions_likelihood_exception=raise_inversion_positions_likelihood_exception,
77+
title_prefix=title_prefix,
78+
use_jax=use_jax,
79+
**kwargs,
80+
)
81+
82+
self.shared_preloads = shared_preloads
83+
84+
def log_likelihood_function(self, instance: af.ModelInstance, shared=None) -> float:
4385
"""
4486
Given an instance of the model, where the model parameters are set via a non-linear search, fit the model
4587
instance to the imaging dataset.
@@ -71,6 +113,11 @@ def log_likelihood_function(self, instance: af.ModelInstance) -> float:
71113
instance
72114
An instance of the model that is being fitted to the data by this analysis (whose parameters have been set
73115
via a non-linear search).
116+
shared
117+
The cross-factor shared state of a `FactorGraphModel`, computed once per evaluation by the lead
118+
factor's `shared_state_from` (see that method). For this analysis it is a `PreloadsImaging`
119+
carrying the exposure-invariant source-plane mesh geometry; when provided it is reused by the fit
120+
instead of being recomputed. `None` (the default, e.g. a standalone fit) leaves behaviour unchanged.
74121
75122
Returns
76123
-------
@@ -83,16 +130,63 @@ def log_likelihood_function(self, instance: af.ModelInstance) -> float:
83130
)
84131

85132
if self._use_jax:
86-
return self.fit_from(instance=instance).figure_of_merit - log_likelihood_penalty
133+
return (
134+
self.fit_from(instance=instance, preloads=shared).figure_of_merit
135+
- log_likelihood_penalty
136+
)
87137

88138
try:
89-
return self.fit_from(instance=instance).figure_of_merit - log_likelihood_penalty
139+
return (
140+
self.fit_from(instance=instance, preloads=shared).figure_of_merit
141+
- log_likelihood_penalty
142+
)
90143
except Exception as e:
91144
raise af.exc.FitException
92145

146+
def shared_state_from(self, instance: af.ModelInstance):
147+
"""
148+
Compute the exposure-invariant source-plane mesh geometry once so it can be shared across the factors
149+
of a multi-exposure `FactorGraphModel` (see `autofit.Analysis.shared_state_from`).
150+
151+
When `shared_preloads` is set, every factor of the graph is an exposure of the same lens sharing an
152+
identical lens model, so the source-plane mesh (the image-mesh centres of this lead exposure,
153+
ray-traced through the shared lens model) is built once here and returned inside a `PreloadsImaging`,
154+
which `FactorGraphModel` forwards as the `shared` argument to every factor's
155+
`log_likelihood_function`. Each exposure then maps its own (offset) data grid onto the shared mesh
156+
instead of computing its own image-mesh and mesh ray-trace, so every exposure reconstructs on an
157+
identical source-pixel grid.
158+
159+
Unlike the interferometer datacube case, the mapper, mapping matrix, curvature matrix and
160+
regularization matrix are NOT shared — per-exposure PSFs and pixel offsets make the first three
161+
per-dataset, and regularization may adapt to per-exposure data.
162+
163+
Returns `None` when the analysis has not opted in (`shared_preloads=False`) or when the model performs
164+
no inversion, in which case no state is shared and every factor fits as normal.
165+
166+
The caller is responsible for the invariance contract: only enable `shared_preloads` when the factors
167+
genuinely share the lens model, so the source-plane mesh really is exposure-invariant. The lead
168+
factor's own `DatasetModel` offset (if any) is applied when the mesh is traced, so the mesh is defined
169+
in the lead exposure's frame.
170+
"""
171+
if not self.shared_preloads:
172+
return None
173+
174+
fit = self.fit_from(instance=instance)
175+
176+
if not fit.perform_inversion:
177+
return None
178+
179+
tracer_to_inversion = fit.tracer_to_inversion
180+
181+
return aa.PreloadsImaging(
182+
source_plane_mesh_grid=tracer_to_inversion.traced_mesh_grid_pg_list,
183+
image_plane_mesh_grid=tracer_to_inversion.image_plane_mesh_grid_pg_list,
184+
)
185+
93186
def fit_from(
94187
self,
95188
instance: af.ModelInstance,
189+
preloads=None,
96190
) -> FitImaging:
97191
"""
98192
Given a model instance create a `FitImaging` object.
@@ -105,9 +199,10 @@ def fit_from(
105199
instance
106200
An instance of the model that is being fitted to the data by this analysis (whose parameters have been set
107201
via a non-linear search).
108-
check_positions
109-
Whether the multiple image positions of the lensed source should be checked, i.e. whether they trace
110-
within the position threshold of one another in the source plane.
202+
preloads
203+
An optional `PreloadsImaging` carrying the exposure-invariant source-plane mesh geometry,
204+
computed once and reused by the fit instead of being rebuilt. Supplied by the multi-exposure
205+
shared-state path (see `shared_state_from`); `None` (the default) fits as normal.
111206
112207
Returns
113208
-------
@@ -137,7 +232,8 @@ def fit_from(
137232
dataset_model=dataset_model,
138233
adapt_images=adapt_images,
139234
settings=self.settings,
140-
xp=self._xp
235+
xp=self._xp,
236+
preloads=preloads,
141237
)
142238

143239
def save_attributes(self, paths: af.DirectoryPaths):
@@ -221,7 +317,7 @@ def _register_fit_imaging_pytrees() -> None:
221317

222318
register_instance_pytree(
223319
FitImaging,
224-
no_flatten=("dataset", "adapt_images", "settings"),
320+
no_flatten=("dataset", "adapt_images", "settings", "preloads"),
225321
)
226322
register_instance_pytree(DatasetModel)
227323
# ``cosmology`` is a fixed physical constant per fit; ride as aux.

autolens/lens/to_inversion.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,11 @@ def image_plane_mesh_grid_pg_list(self) -> List[List]:
311311
-------
312312
The list of lists of image-plane mesh grids grouped by plane.
313313
"""
314+
if (
315+
self._preloads is not None
316+
and self._preloads.image_plane_mesh_grid is not None
317+
):
318+
return self._preloads.image_plane_mesh_grid
314319

315320
image_plane_mesh_grid_list_of_planes = []
316321

@@ -351,6 +356,16 @@ def traced_mesh_grid_pg_list(self) -> List[List]:
351356
-------
352357
The list of lists of traced mesh grids grouped by plane.
353358
"""
359+
if (
360+
self._preloads is not None
361+
and self._preloads.source_plane_mesh_grid is not None
362+
):
363+
# The shared-state path (e.g. `AnalysisImaging.shared_state_from`): the source-plane
364+
# mesh geometry was traced once from the lead dataset and is reused here, so this
365+
# dataset skips the image-mesh computation and mesh ray-trace. Its own (offset) data
366+
# grid is still traced and mapped onto the shared mesh in `mapper_galaxy_dict`.
367+
return self._preloads.source_plane_mesh_grid
368+
354369
image_plane_mesh_grid_pg_list = self.image_plane_mesh_grid_pg_list
355370

356371
traced_mesh_grid_pg_list = []

test_autolens/imaging/model/test_analysis_imaging.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,144 @@ def test__positions__likelihood_overwrites__changes_likelihood__double_source_pl
125125

126126
assert analysis_log_likelihood == pytest.approx(-44097289521.734665, 1.0e-4)
127127

128+
129+
130+
def _shared_mesh_analysis(masked_imaging_7x7, shared_preloads):
131+
"""
132+
An `AnalysisImaging` with an image-mesh (`Overlay`) + `Delaunay` pixelization, the regime the
133+
multi-exposure shared-state path applies to (the source-plane mesh is traced from image-plane
134+
mesh centres, so it can be shared across exposures).
135+
"""
136+
import autoarray as aa
137+
138+
lens = al.Galaxy(
139+
redshift=0.5,
140+
mass=al.mp.Isothermal(centre=(0.1, 0.1), einstein_radius=1.0),
141+
)
142+
143+
pixelization = al.Pixelization(
144+
mesh=al.mesh.Delaunay(pixels=9, zeroed_pixels=0),
145+
regularization=al.reg.Constant(coefficient=0.01),
146+
)
147+
148+
source = al.Galaxy(redshift=1.0, pixelization=pixelization)
149+
150+
image_mesh = al.image_mesh.Overlay(shape=(3, 3))
151+
image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from(
152+
mask=masked_imaging_7x7.mask,
153+
)
154+
155+
adapt_images = al.AdaptImages(
156+
galaxy_name_image_dict={
157+
str(("galaxies", "source")): masked_imaging_7x7.data,
158+
},
159+
galaxy_name_image_plane_mesh_grid_dict={
160+
str(("galaxies", "source")): image_plane_mesh_grid,
161+
},
162+
)
163+
164+
model = af.Collection(galaxies=af.Collection(lens=lens, source=source))
165+
166+
analysis = al.AnalysisImaging(
167+
dataset=masked_imaging_7x7,
168+
adapt_images=adapt_images,
169+
use_jax=False,
170+
shared_preloads=shared_preloads,
171+
raise_inversion_positions_likelihood_exception=False,
172+
)
173+
174+
return model, analysis
175+
176+
177+
def test__shared_state_from__mesh_reused__figure_of_merit_unchanged(
178+
masked_imaging_7x7,
179+
):
180+
import autoarray as aa
181+
182+
model, analysis = _shared_mesh_analysis(masked_imaging_7x7, shared_preloads=True)
183+
instance = model.instance_from_unit_vector([])
184+
185+
# `shared_state_from` builds a `PreloadsImaging` carrying the source-plane mesh geometry (the
186+
# exposure-invariant quantity) — NOT the mapper / curvature matrix / regularization matrix,
187+
# which are per-exposure for imaging (PSFs, offsets, adaptive regularization).
188+
shared = analysis.shared_state_from(instance=instance)
189+
assert isinstance(shared, aa.PreloadsImaging)
190+
assert shared.source_plane_mesh_grid is not None
191+
assert shared.image_plane_mesh_grid is not None
192+
assert shared.curvature_matrix is None
193+
assert shared.mapper_galaxy_dict is None
194+
195+
# The preloaded mesh is reused by the fit (identity) and leaves the figure of merit unchanged.
196+
fit_unshared = analysis.fit_from(instance=instance)
197+
fom_unshared = fit_unshared.figure_of_merit
198+
199+
fit_shared = analysis.fit_from(instance=instance, preloads=shared)
200+
201+
assert (
202+
fit_shared.tracer_to_inversion.traced_mesh_grid_pg_list
203+
is shared.source_plane_mesh_grid
204+
)
205+
assert fit_shared.figure_of_merit == pytest.approx(fom_unshared)
206+
207+
# The full `log_likelihood_function` with the shared object matches the unshared call.
208+
assert analysis.log_likelihood_function(
209+
instance=instance, shared=shared
210+
) == pytest.approx(analysis.log_likelihood_function(instance=instance))
211+
212+
213+
def test__shared_state_from__returns_none_when_not_opted_in(masked_imaging_7x7):
214+
model, analysis = _shared_mesh_analysis(masked_imaging_7x7, shared_preloads=False)
215+
instance = model.instance_from_unit_vector([])
216+
217+
assert analysis.shared_state_from(instance=instance) is None
218+
219+
220+
def test__shared_state_from__returns_none_when_no_inversion(masked_imaging_7x7):
221+
lens = al.Galaxy(redshift=0.5, light=al.lp.Sersic(intensity=0.1))
222+
223+
model = af.Collection(galaxies=af.Collection(lens=lens))
224+
instance = model.instance_from_unit_vector([])
225+
226+
analysis = al.AnalysisImaging(
227+
dataset=masked_imaging_7x7, use_jax=False, shared_preloads=True
228+
)
229+
230+
assert analysis.shared_state_from(instance=instance) is None
231+
232+
233+
def _factor_graph_log_likelihood(masked_imaging_7x7, shared_preloads):
234+
factors = []
235+
model = None
236+
for _ in range(2):
237+
model, analysis = _shared_mesh_analysis(masked_imaging_7x7, shared_preloads)
238+
factors.append(af.AnalysisFactor(prior_model=model.copy(), analysis=analysis))
239+
240+
factor_graph = af.FactorGraphModel(*factors)
241+
instance = factor_graph.global_prior_model.instance_from_unit_vector([])
242+
return factor_graph.log_likelihood_function(instance)
243+
244+
245+
def test__factor_graph__shared_vs_unshared_equal(masked_imaging_7x7):
246+
ll_unshared = _factor_graph_log_likelihood(masked_imaging_7x7, shared_preloads=False)
247+
ll_shared = _factor_graph_log_likelihood(masked_imaging_7x7, shared_preloads=True)
248+
249+
print(f"\nunshared={ll_unshared} shared={ll_shared}")
250+
assert ll_shared == pytest.approx(ll_unshared, rel=1e-10)
251+
252+
253+
def test__factor_graph__shared_state_computed_once(masked_imaging_7x7, monkeypatch):
254+
calls = {"n": 0}
255+
256+
original = al.AnalysisImaging.shared_state_from
257+
258+
def counting(self, instance):
259+
result = original(self, instance)
260+
if result is not None:
261+
calls["n"] += 1
262+
return result
263+
264+
monkeypatch.setattr(al.AnalysisImaging, "shared_state_from", counting)
265+
266+
_factor_graph_log_likelihood(masked_imaging_7x7, shared_preloads=True)
267+
268+
assert calls["n"] == 1

0 commit comments

Comments
 (0)