Skip to content

Commit 5dbe0ce

Browse files
Jammy2211claude
authored andcommitted
feat: analytically-solved point-source likelihood variants (#657)
FitPositionsSourceSolved (tensor-weighted, marginalized centre; Lombardi 2024 arXiv:2406.15280 §5.1), FitPositionsImagePairAllSolved / FitPositionsImagePairRepeatSolved (solved centre driving the existing PointSolver forward solve), FitFluxesSolved (analytic flux, flux-space, magnification-first), FitTimeDelaysSolved (analytic reference time); SolvedCentre mixin; fit_flux_cls / fit_time_delays_cls hooks; pytree registration incl. FitPositionsSource; informative mismatch errors; docstring truth sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 56dfacf commit 5dbe0ce

22 files changed

Lines changed: 1527 additions & 23 deletions

autolens/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,18 @@
115115
from .point.dataset import output_to_csv
116116
from .point.fit.dataset import FitPointDataset
117117
from .point.fit.fluxes import FitFluxes
118+
from .point.fit.fluxes import FitFluxesSolved
118119
from .point.fit.times_delays import FitTimeDelays
120+
from .point.fit.times_delays import FitTimeDelaysSolved
121+
from .point.fit.solved import SolvedCentre
119122
from .point.fit.positions.image.abstract import AbstractFitPositionsImagePair
120123
from .point.fit.positions.image.pair import FitPositionsImagePair
121124
from .point.fit.positions.image.pair_all import FitPositionsImagePairAll
125+
from .point.fit.positions.image.pair_all import FitPositionsImagePairAllSolved
122126
from .point.fit.positions.image.pair_repeat import FitPositionsImagePairRepeat
127+
from .point.fit.positions.image.pair_repeat import FitPositionsImagePairRepeatSolved
123128
from .point.fit.positions.source.separations import FitPositionsSource
129+
from .point.fit.positions.source.separations import FitPositionsSourceSolved
124130
from .point.max_separation import SourceMaxSeparation
125131
from .point.model.analysis import AnalysisPoint
126132
from .point.solver import PointSolver

autolens/point/fit/abstract.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,26 @@ def source_plane_coordinate(self) -> Tuple[float, float]:
146146
Returns the centre of the point-source in the source-plane, which is used when computing the model
147147
image-plane positions from the tracer.
148148
149+
This is the single funnel every position-based fit reads the source-plane centre from. By default it
150+
reads the `centre` of the paired point-source profile (a free model parameter on `ag.ps.Point` /
151+
`ag.ps.PointFlux`). The `autolens.point.fit.solved.SolvedCentre` mixin overrides this property on the
152+
`*Solved` fit classes (e.g. `FitPositionsSourceSolved`) to instead return a centre solved for
153+
analytically given the current tracer.
154+
149155
Returns
150156
-------
151157
The (y,x) arc-second coordinates of the point-source in the source-plane.
152158
"""
159+
if not hasattr(self.profile, "centre"):
160+
raise exc.PointExtractionException(
161+
f"The point-source profile paired to dataset '{self.name}' "
162+
f"({self.profile.__class__.__name__}) has no `centre` attribute, so {self.__class__.__name__} "
163+
f"cannot read a source-plane coordinate from it. Use a `centre`-bearing profile (e.g. "
164+
f"`ag.ps.Point` / `ag.ps.PointFlux`), or use one of the analytically-solved fit classes (e.g. "
165+
f"`FitPositionsSourceSolved`, `FitPositionsImagePairAllSolved`, "
166+
f"`FitPositionsImagePairRepeatSolved`) which solve for the source-plane centre analytically "
167+
f"and require a parameter-free profile such as `ag.ps.PointSolved`."
168+
)
153169
return self.profile.centre
154170

155171
@property

autolens/point/fit/dataset.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def __init__(
3232
tracer: Tracer,
3333
solver: PointSolver,
3434
fit_positions_cls=FitPositionsImagePair,
35+
fit_flux_cls=FitFluxes,
36+
fit_time_delays_cls=FitTimeDelays,
3537
xp=np,
3638
):
3739
"""
@@ -84,6 +86,12 @@ def __init__(
8486
fit_positions_cls
8587
The class used to fit the positions of the point source dataset, which could be an image-plane or
8688
source-plane chi-squared.
89+
fit_flux_cls
90+
The class used to fit the fluxes of the point source dataset, which could be a free-flux
91+
(`FitFluxes`) or analytically-solved-flux (`FitFluxesSolved`) fit.
92+
fit_time_delays_cls
93+
The class used to fit the time delays of the point source dataset, which could be the
94+
min-subtraction (`FitTimeDelays`) or analytically-solved-reference-time (`FitTimeDelaysSolved`) fit.
8795
profile
8896
Manually input the profile of the point source, which is used instead of the one extracted from the
8997
tracer via name pairing if that profile is not found.
@@ -95,6 +103,8 @@ def __init__(
95103
profile = self.tracer.extract_profile(profile_name=dataset.name)
96104

97105
self.fit_positions_cls = fit_positions_cls
106+
self.fit_flux_cls = fit_flux_cls
107+
self.fit_time_delays_cls = fit_time_delays_cls
98108

99109
try:
100110
self.positions = self.fit_positions_cls(
@@ -111,7 +121,7 @@ def __init__(
111121

112122
try:
113123
if dataset.fluxes is not None:
114-
self.flux = FitFluxes(
124+
self.flux = self.fit_flux_cls(
115125
name=dataset.name,
116126
data=dataset.fluxes,
117127
noise_map=dataset.fluxes_noise_map,
@@ -127,7 +137,7 @@ def __init__(
127137

128138
try:
129139
if dataset.time_delays is not None:
130-
self.time_delays = FitTimeDelays(
140+
self.time_delays = self.fit_time_delays_cls(
131141
name=dataset.name,
132142
data=dataset.time_delays,
133143
noise_map=dataset.time_delays_noise_map,

autolens/point/fit/fluxes.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,144 @@ def chi_squared(self) -> float:
146146
return ag.util.fit.chi_squared_from(
147147
chi_squared_map=self.chi_squared_map.array,
148148
)
149+
150+
151+
class FitFluxesSolved(AbstractFitPoint):
152+
"""
153+
Fits the fluxes of a point source dataset with the source-plane flux solved for analytically (in flux space,
154+
magnification-first), following Lombardi 2024 (arXiv:2406.15280) §6.1, rather than read from a free `flux`
155+
model parameter.
156+
157+
With image-plane magnifications `µᵢ` (`magnifications_at_positions`), observed fluxes `f̂ᵢ` and noise `σᵢ`:
158+
159+
`F* = (Σᵢ µᵢ f̂ᵢ/σᵢ²) / (Σᵢ µᵢ²/σᵢ²)` (`solved_flux`)
160+
161+
with model fluxes `µᵢF*` (`model_data`), a standard chi-squared and noise normalization, and the likelihood
162+
analytically marginalized over `F*` (flat prior):
163+
164+
`log_likelihood = -0.5*(χ² + noise_norm) - 0.5*log((Σᵢ µᵢ²/σᵢ²)/(2π))`
165+
166+
The paper's magnitude-space form is not used here: the flux noise maps in this fit are flux-space Gaussians,
167+
and converting to magnitude space would change the error model, not just its parametrization.
168+
169+
Works with any profile that has **no** `flux` attribute (`ag.ps.Point` or `ag.ps.PointSolved`); a profile
170+
with a `flux` attribute (`ag.ps.PointFlux`) raises, since its flux prior would otherwise be sampled by the
171+
non-linear search but silently ignored by the analytic solve. Use `FitFluxes` for a free-flux fit.
172+
"""
173+
174+
def __init__(
175+
self,
176+
name: str,
177+
data: aa.ArrayIrregular,
178+
noise_map: aa.ArrayIrregular,
179+
positions: aa.Grid2DIrregular,
180+
tracer: Tracer,
181+
profile: Optional[ag.ps.Point] = None,
182+
xp=np,
183+
):
184+
"""
185+
Parameters
186+
----------
187+
name
188+
The name of the point source dataset which is paired to a `Point` profile.
189+
data
190+
The observed fluxes of the point source.
191+
noise_map
192+
The noise-map of the fluxes which are used to compute the log likelihood.
193+
positions
194+
The image-plane positions of the point source where the fluxes and magnifications are calculated.
195+
tracer
196+
The tracer of galaxies whose point source profile is used to fit the fluxes.
197+
profile
198+
Manually input the profile of the point source, used instead of one extracted from the tracer.
199+
"""
200+
self.positions = positions
201+
202+
super().__init__(
203+
name=name,
204+
data=data,
205+
noise_map=noise_map,
206+
tracer=tracer,
207+
solver=None,
208+
profile=profile,
209+
xp=xp,
210+
)
211+
212+
if hasattr(self.profile, "flux"):
213+
raise exc.PointExtractionException(
214+
f"For the point-source named {name} the extracted point source was the class "
215+
f"{self.profile.__class__.__name__}, which has a `flux` attribute. `FitFluxesSolved` solves "
216+
f"for the source flux analytically (F*), so a free `flux` prior would be sampled by the "
217+
f"non-linear search but silently ignored. Use `FitFluxes` with `ag.ps.PointFlux` for a "
218+
f"free-flux fit, or use a profile with no `flux` attribute (e.g. `ag.ps.Point` / "
219+
f"`ag.ps.PointSolved`) with `FitFluxesSolved`."
220+
)
221+
222+
@property
223+
def flux_precision_sum(self) -> float:
224+
"""
225+
`Σᵢ µᵢ²/σᵢ²` — the precision of the solved flux `F*`, and the marginalization normalization.
226+
"""
227+
mu = self.magnifications_at_positions.array
228+
sigma_squared = self.noise_map.array**2.0
229+
return self._xp.sum(mu**2.0 / sigma_squared)
230+
231+
@property
232+
def solved_flux(self) -> float:
233+
"""
234+
`F* = (Σᵢ µᵢ f̂ᵢ/σᵢ²) / (Σᵢ µᵢ²/σᵢ²)`.
235+
"""
236+
mu = self.magnifications_at_positions.array
237+
f_hat = self.data.array
238+
sigma_squared = self.noise_map.array**2.0
239+
numerator = self._xp.sum(mu * f_hat / sigma_squared)
240+
return numerator / self.flux_precision_sum
241+
242+
@property
243+
def model_data(self) -> aa.ArrayIrregular:
244+
"""
245+
The model fluxes `µᵢF*`.
246+
"""
247+
return aa.ArrayIrregular(
248+
values=self.magnifications_at_positions.array * self.solved_flux
249+
)
250+
251+
@property
252+
def model_fluxes(self) -> aa.ArrayIrregular:
253+
return self.model_data
254+
255+
@property
256+
def residual_map(self) -> aa.ArrayIrregular:
257+
"""
258+
Returns the difference between the observed and model fluxes of the point source.
259+
"""
260+
residual_map = super().residual_map
261+
262+
return aa.ArrayIrregular(values=residual_map)
263+
264+
@property
265+
def chi_squared(self) -> float:
266+
"""
267+
Returns the chi-squared of the fit of the point source fluxes.
268+
"""
269+
return ag.util.fit.chi_squared_from(
270+
chi_squared_map=self.chi_squared_map.array,
271+
)
272+
273+
@property
274+
def marginalization_term(self) -> float:
275+
"""
276+
The analytic-marginalization contribution to the log likelihood from integrating out the (flat-prior)
277+
source flux: `-0.5 * log((Σᵢ µᵢ²/σᵢ²)/(2π))`.
278+
"""
279+
return -0.5 * self._xp.log(self.flux_precision_sum / (2.0 * np.pi))
280+
281+
@property
282+
def log_likelihood(self) -> float:
283+
"""
284+
`log_likelihood = -0.5*(χ² + noise_norm) - 0.5*log((Σᵢ µᵢ²/σᵢ²)/(2π))`.
285+
"""
286+
return (
287+
-0.5 * (self.chi_squared + self.noise_normalization)
288+
+ self.marginalization_term
289+
)

autolens/point/fit/positions/abstract.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ def __init__(
4444
4545
The fit performs the following steps:
4646
47-
1) Determine the source-plane centre of the point source, which could be a free model parameter or computed
48-
as the barycenter of ray-traced positions in the source-plane, using name pairing (see below).
47+
1) Determine the source-plane centre of the point source, which is either a free model parameter read
48+
from the profile's `centre` (`ag.ps.Point` / `ag.ps.PointFlux`) or, for the `*Solved` fit classes
49+
(e.g. `FitPositionsSourceSolved`), solved for analytically given the current tracer (see
50+
`autolens.point.fit.solved.SolvedCentre`), using name pairing (see below).
4951
5052
2) Using the sub-class specific chi-squared, compute the residuals of each image-plane position, chi-squared
5153
and overall log likelihood of the fit.

autolens/point/fit/positions/image/abstract.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@ def __init__(
4545
4646
The fit performs the following steps:
4747
48-
1) Determine the source-plane centre of the point source, which could be a free model parameter or computed
49-
as the barycenter of ray-traced positions in the source-plane, using name pairing (see below).
48+
1) Determine the source-plane centre of the point source, which is either a free model parameter read
49+
from the profile's `centre` (`ag.ps.Point` / `ag.ps.PointFlux`) or, for the `*Solved` fit classes
50+
(e.g. `FitPositionsImagePairAllSolved`, `FitPositionsImagePairRepeatSolved`), solved for
51+
analytically given the current tracer (see `autolens.point.fit.solved.SolvedCentre`), using name
52+
pairing (see below).
5053
5154
2) Determine the image-plane model positions using the `PointSolver` and the source-plane centre of the point
5255
source (e.g. ray tracing triangles to and from the image and source planes), including accounting for

autolens/point/fit/positions/image/pair.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,16 @@ class FitPositionsImagePair(AbstractFitPositionsImagePair):
2424
contributes the ``no_image_residual`` floor. ``FitPositionsImagePairRepeat`` remains the model-fit default;
2525
it additionally offers over-prediction policies.
2626
27+
**No analytically-solved-centre variant**: unlike ``FitPositionsImagePairAll`` /
28+
``FitPositionsImagePairRepeat``, this class has no ``*Solved`` counterpart. Its Hungarian assignment
29+
(``scipy.optimize.linear_sum_assignment``) is not JAX-jittable, and its behaviour is superseded by
30+
``FitPositionsImagePairAllSolved`` / ``FitPositionsImagePairRepeatSolved`` for solved-centre fits.
31+
2732
The fit performs the following steps:
2833
29-
1) Determine the source-plane centre of the point source, which could be a free model parameter or computed
30-
as the barycenter of ray-traced positions in the source-plane, using name pairing (see below).
34+
1) Determine the source-plane centre of the point source, which is either a free model parameter read from
35+
the profile's `centre` (`ag.ps.Point` / `ag.ps.PointFlux`) — this class has no `*Solved` counterpart, see
36+
above — using name pairing (see below).
3137
3238
2) Determine the image-plane model positions using the `PointSolver` and the source-plane centre of the point
3339
source (e.g. ray tracing triangles to and from the image and source planes), including accounting for

autolens/point/fit/positions/image/pair_all.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import numpy as np
22

33
from autolens.point.fit.positions.image.abstract import AbstractFitPositionsImagePair
4+
from autolens.point.fit.solved import SolvedCentre
45

56

67
class FitPositionsImagePairAll(AbstractFitPositionsImagePair):
@@ -22,8 +23,10 @@ class FitPositionsImagePairAll(AbstractFitPositionsImagePair):
2223
2324
The fit performs the following steps:
2425
25-
1) Determine the source-plane centre of the point source, which could be a free model parameter or computed
26-
as the barycenter of ray-traced positions in the source-plane, using name pairing (see below).
26+
1) Determine the source-plane centre of the point source, which is either a free model parameter read from
27+
the profile's `centre` (`ag.ps.Point` / `ag.ps.PointFlux`) or, for `FitPositionsImagePairAllSolved`,
28+
solved for analytically given the current tracer (see `autolens.point.fit.solved.SolvedCentre`), using
29+
name pairing (see below).
2730
2831
2) Determine the image-plane model positions using the `PointSolver` and the source-plane centre of the point
2932
source (e.g. ray tracing triangles to and from the image and source planes), including accounting for
@@ -152,3 +155,22 @@ def chi_squared(self) -> float:
152155
-self._xp.log(n_permutations)
153156
+ self._xp.sum(self.all_permutations_log_likelihoods())
154157
)
158+
159+
160+
class FitPositionsImagePairAllSolved(SolvedCentre, FitPositionsImagePairAll):
161+
"""
162+
``FitPositionsImagePairAll`` with the source-plane centre fed into the `PointSolver` forward solve
163+
(`model_data`, inherited unchanged from `AbstractFitPositionsImagePair`) solved for analytically
164+
(`SolvedCentre.source_plane_coordinate`, `β*`) rather than read from a free `centre` model parameter.
165+
166+
This is **not** a result from Lombardi 2024 (arXiv:2406.15280) — the paper never substitutes a solved
167+
source-plane centre into an image-plane likelihood. It is an extension in the spirit of glafic's
168+
source-position-optimized image-plane chi-squared: the all-to-all pairing chi-squared itself
169+
(`chi_squared`, `all_permutations_log_likelihoods`) is completely unchanged from `FitPositionsImagePairAll`.
170+
171+
Must be paired (by name) with a parameter-free profile such as `ag.ps.PointSolved`: a `centre`-bearing
172+
profile (`ag.ps.Point` / `ag.ps.PointFlux`) raises (see `SolvedCentre.source_plane_coordinate`), since its
173+
centre priors would otherwise be sampled but silently ignored.
174+
"""
175+
176+
_non_solved_alternative_name = "FitPositionsImagePairAll"

autolens/point/fit/positions/image/pair_repeat.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import autogalaxy as ag
55

66
from autolens.point.fit.positions.image.abstract import AbstractFitPositionsImagePair
7+
from autolens.point.fit.solved import SolvedCentre
78

89

910
class FitPositionsImagePairRepeat(AbstractFitPositionsImagePair):
@@ -14,8 +15,10 @@ class FitPositionsImagePairRepeat(AbstractFitPositionsImagePair):
1415
1516
The fit performs the following steps:
1617
17-
1) Determine the source-plane centre of the point source, which could be a free model parameter or computed
18-
as the barycenter of ray-traced positions in the source-plane, using name pairing (see below).
18+
1) Determine the source-plane centre of the point source, which is either a free model parameter read from
19+
the profile's `centre` (`ag.ps.Point` / `ag.ps.PointFlux`) or, for `FitPositionsImagePairRepeatSolved`,
20+
solved for analytically given the current tracer (see `autolens.point.fit.solved.SolvedCentre`), using
21+
name pairing (see below).
1922
2023
2) Determine the image-plane model positions using the `PointSolver` and the source-plane centre of the point
2124
source (e.g. ray tracing triangles to and from the image and source planes), including accounting for
@@ -217,3 +220,23 @@ def chi_squared(self) -> float:
217220
return chi_squared + self._xp.sum(
218221
(self.unmatched_model_penalty_map / noise_mean) ** 2.0
219222
)
223+
224+
225+
class FitPositionsImagePairRepeatSolved(SolvedCentre, FitPositionsImagePairRepeat):
226+
"""
227+
``FitPositionsImagePairRepeat`` with the source-plane centre fed into the `PointSolver` forward solve
228+
(`model_data`, inherited unchanged from `AbstractFitPositionsImagePair`) solved for analytically
229+
(`SolvedCentre.source_plane_coordinate`, `β*`) rather than read from a free `centre` model parameter.
230+
231+
This is **not** a result from Lombardi 2024 (arXiv:2406.15280) — the paper never substitutes a solved
232+
source-plane centre into an image-plane likelihood. It is an extension in the spirit of glafic's
233+
source-position-optimized image-plane chi-squared: the pairing chi-squared itself (`chi_squared`,
234+
`residual_map`, the over-/under-prediction policies) is completely unchanged from
235+
`FitPositionsImagePairRepeat`.
236+
237+
Must be paired (by name) with a parameter-free profile such as `ag.ps.PointSolved`: a `centre`-bearing
238+
profile (`ag.ps.Point` / `ag.ps.PointFlux`) raises (see `SolvedCentre.source_plane_coordinate`), since its
239+
centre priors would otherwise be sampled but silently ignored.
240+
"""
241+
242+
_non_solved_alternative_name = "FitPositionsImagePairRepeat"

0 commit comments

Comments
 (0)