Skip to content

Commit 0cebe05

Browse files
Jammy2211Jammy2211
authored andcommitted
feat: stabilize PairAll mixture via log-sum-exp; add FitPositionsSource weighting option
Phase A of the point-source defaults campaign (#678): - FitPositionsImagePairAll.all_permutations_log_likelihoods now reduces via a max-shifted log-sum-exp: identical where the literal log(sum(exp(...))) was finite, finite (not -inf) at >~38 sigma mismatch, restoring gradient flow across the approach to the basin. Zero-model-position and inf-padded solver rows behave exactly as before. - FitPositionsSource gains a `weighting` class attribute: "magnification" (default) is unchanged behaviour; "jacobian" opts into the per-image precision-tensor chi-squared with the observed-plane normalization matching FitPositionsSourceSolved, reusing precision_tensor_components_from.
1 parent 8b9e20b commit 0cebe05

5 files changed

Lines changed: 277 additions & 31 deletions

File tree

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

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,26 +106,46 @@ def all_permutations_log_likelihoods(self) -> np.ndarray:
106106
P(data_0 | model_1) * P(data_1 | model_1)
107107
108108
This is every way in which the coordinates generated by the model can explain the observed coordinates.
109+
110+
The reduction over model positions is a max-shifted log-sum-exp rather than a literal
111+
`log(sum(exp(...)))`: exponentiating first underflows to 0 once the best model/observed pairing is
112+
~38 sigma or worse, turning the log likelihood into `-inf` and killing gradient flow across the
113+
exact region gradient searches must traverse to find the basin. The shifted form is mathematically
114+
identical wherever the literal form is finite, and stays finite (the max term contributes exactly 0
115+
after the shift) at arbitrarily large mismatch.
109116
"""
110117

111118
model_data = self.model_data.array
112119

120+
def log_sum_exp(log_ps):
121+
# `initial` covers the zero-model-positions case (an empty `log_ps`), where a bare `max`
122+
# raises: the -inf sentinel is clamped to 0 below, giving `log(sum of nothing) = -inf`,
123+
# exactly the literal form's result, which `chi_squared`'s `has_image` fallback replaces.
124+
max_log_p = self._xp.max(log_ps, initial=-np.inf)
125+
# With no finite model position every log_p is -inf, and shifting by a -inf max would
126+
# produce NaN (`-inf - -inf`) inside `exp` — including under `jax.grad`, where a NaN in
127+
# the branch `chi_squared`'s `xp.where` discards still poisons the gradient. Clamp the
128+
# shift to 0 so this case reduces to the literal form's `log(0) = -inf`, which the
129+
# `has_image` fallback in `chi_squared` then replaces.
130+
max_log_p = self._xp.where(
131+
self._xp.isfinite(max_log_p), max_log_p, 0.0
132+
)
133+
return max_log_p + self._xp.log(
134+
self._xp.sum(self._xp.exp(log_ps - max_log_p))
135+
)
136+
113137
return self._xp.array(
114138
[
115-
self._xp.log(
116-
self._xp.sum(
117-
self._xp.array(
118-
[
119-
self._xp.exp(
120-
self.log_p(
121-
data_position,
122-
model_position,
123-
sigma,
124-
)
125-
)
126-
for model_position in model_data
127-
]
128-
)
139+
log_sum_exp(
140+
self._xp.array(
141+
[
142+
self.log_p(
143+
data_position,
144+
model_position,
145+
sigma,
146+
)
147+
for model_position in model_data
148+
]
129149
)
130150
)
131151
for data_position, sigma in zip(self.data, self.noise_map)

autolens/point/fit/positions/source/separations.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929

3030

3131
class FitPositionsSource(AbstractFitPositions):
32+
#: How each back-traced position's residual from the source-plane centre is weighted:
33+
#: `"magnification"` — the traditional scalar `µᵢ²/σᵢ²` weighting with the magnified-noise
34+
#: normalization (the long-standing behaviour of this class, and the Lenstool convention);
35+
#: `"jacobian"` — the per-image precision tensor `Wᵢ = Aᵢ⁻ᵀΘᵢAᵢ⁻¹` with the observed-plane
36+
#: normalization, matching `FitPositionsSourceSolved` but with the centre a free parameter.
37+
weighting = "magnification"
38+
3239
def __init__(
3340
self,
3441
name: str,
@@ -66,6 +73,13 @@ def __init__(
6673
6774
7) Sum the chi-squared values to compute the overall log likelihood of the fit.
6875
76+
Steps 4-6 describe the default `weighting = "magnification"` scalar convention. Setting the
77+
`weighting` class attribute to `"jacobian"` instead weights each vector residual `β̂ᵢ − c` (with `c`
78+
the profile's free `centre`) by the per-image precision tensor `Wᵢ = Aᵢ⁻ᵀΘᵢAᵢ⁻¹` (see
79+
`autolens.point.fit.solved.precision_tensor_components_from`), with the observed-plane noise
80+
normalization matching `FitPositionsSourceSolved` — the same tensor likelihood as that class, but
81+
with the centre sampled as a free parameter rather than solved and marginalized.
82+
6983
Point source fitting uses name pairing, whereby the `name` of the `Point` object is paired to the name of the
7084
point source dataset to ensure that point source datasets are fitted to the correct point source.
7185
@@ -136,32 +150,68 @@ def residual_map(self) -> aa.ArrayIrregular:
136150
coordinate=self.source_plane_coordinate
137151
)
138152

153+
@property
154+
def residual_vectors(self) -> np.ndarray:
155+
"""
156+
The (n_positions, 2) array of vector residuals `β̂ᵢ − c`: the back-traced source-plane positions
157+
minus the source-plane centre `c` (here the profile's free `centre`; `FitPositionsSourceSolved`
158+
overrides this to use the solved `β*` via `_beta_hat`, tolerating plain-ndarray test inputs).
159+
"""
160+
beta_hat = self.model_data.array
161+
centre_y, centre_x = self.source_plane_coordinate
162+
centre = self._xp.array([centre_y, centre_x])
163+
return beta_hat - centre
164+
139165
@property
140166
def chi_squared_map(self) -> float:
141167
"""
142-
Returns the chi-squared of the point-source source-plane fit, which is the sum of the squared residuals
143-
multiplied by the magnifications squared, divided by the noise-map values squared.
168+
Returns the chi-squared of the point-source source-plane fit.
169+
170+
For `weighting = "magnification"` this is the squared residuals multiplied by the magnifications
171+
squared, divided by the noise-map values squared. For `weighting = "jacobian"` it is the per-image
172+
quadratic form `(β̂ᵢ−c)ᵀ Wᵢ (β̂ᵢ−c)` with the precision tensor `Wᵢ = Aᵢ⁻ᵀΘᵢAᵢ⁻¹`.
144173
"""
174+
if self.weighting == "magnification":
175+
return self.residual_map**2.0 / (
176+
self.magnifications_at_positions.array**-2.0
177+
* self.noise_map.array**2.0
178+
)
145179

146-
return self.residual_map**2.0 / (
147-
self.magnifications_at_positions.array**-2.0 * self.noise_map.array**2.0
148-
)
180+
w11, w12, w21, w22 = precision_tensor_components_from(self, self.weighting)
181+
182+
delta = self.residual_vectors
183+
dy = delta[:, 0]
184+
dx = delta[:, 1]
185+
186+
terms = dy * (w11 * dy + w12 * dx) + dx * (w21 * dy + w22 * dx)
187+
188+
return aa.ArrayIrregular(values=terms)
149189

150190
@property
151191
def noise_normalization(self) -> float:
152192
"""
153-
Returns the normalization of the noise-map, which is the sum of the noise-map values squared.
193+
Returns the noise normalization of the fit's Gaussian likelihood.
194+
195+
For `weighting = "magnification"` this is the long-standing magnified-noise source-plane-data
196+
convention `Σᵢ log(2π µᵢ⁻²σᵢ²)`. For `weighting = "jacobian"` it is the observed-plane
197+
(model-independent) convention `Σᵢ log((2π)² σᵢ⁴)` matching `FitPositionsSourceSolved` (see that
198+
class's docstring for why a model-dependent normalization would spuriously favour
199+
high-magnification models).
154200
"""
155-
return self._xp.sum(
156-
self._xp.log(
157-
2
158-
* np.pi
159-
* (
160-
self.magnifications_at_positions.array**-2.0
161-
* self.noise_map.array**2.0
201+
if self.weighting == "magnification":
202+
return self._xp.sum(
203+
self._xp.log(
204+
2
205+
* np.pi
206+
* (
207+
self.magnifications_at_positions.array**-2.0
208+
* self.noise_map.array**2.0
209+
)
162210
)
163211
)
164-
)
212+
213+
sigma_sq = self.noise_map.array**2.0
214+
return self._xp.sum(self._xp.log((2.0 * np.pi) ** 2.0 * sigma_sq**2.0))
165215

166216
@property
167217
def log_likelihood(self) -> float:

autolens/point/fit/solved.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ def precision_tensor_components_from(fit, weighting: str) -> Tuple:
113113

114114
if weighting != "jacobian":
115115
raise exc.PointProfileMismatchException(
116-
f"Unsupported weighting '{weighting}' for the analytically-solved source-plane centre. "
117-
f"Valid options are 'jacobian' (tensor weighting, the default) or 'magnification' "
118-
f"(scalar isotropic weighting)."
116+
f"Unsupported weighting '{weighting}' for the source-plane position residuals. "
117+
f"Valid options are 'jacobian' (tensor weighting, the default of the *Solved fit classes) or "
118+
f"'magnification' (scalar isotropic weighting, the default of `FitPositionsSource`)."
119119
)
120120

121121
lens_calc = _lens_calc_for(fit)

test_autolens/point/fit/positions/image/test_pair_all.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,73 @@ def test__no_model_positions__finite_no_image_floor_matching_siblings(data, nois
215215
)
216216

217217

218+
def test__extreme_mismatch__log_sum_exp_stays_finite(data, noise_map):
219+
"""
220+
Regression: the literal `log(sum(exp(log_p)))` underflows to `log(0) = -inf` once the best
221+
model/observed pairing is ~38 sigma or worse (`exp` underflows below the smallest float64),
222+
strangling gradient flow across the exact region gradient searches traverse to find the basin.
223+
The max-shifted log-sum-exp must stay finite at arbitrarily large mismatch and equal the
224+
directly-computed shifted reduction.
225+
"""
226+
model_positions = al.Grid2DIrregular([(40.0, 40.0), (50.0, 50.0)])
227+
228+
fit = al.FitPositionsImagePairAll(
229+
name="point_0",
230+
data=data,
231+
noise_map=noise_map,
232+
tracer=tracer,
233+
solver=al.mock.MockPointSolver(model_positions),
234+
)
235+
236+
log_likelihoods = fit.all_permutations_log_likelihoods()
237+
238+
assert np.all(np.isfinite(log_likelihoods))
239+
assert np.isfinite(fit.chi_squared)
240+
241+
# ~56 sigma worst pairing: every log_p is far below the ~-745 underflow threshold of exp().
242+
for data_position, sigma, log_likelihood in zip(data, noise_map, log_likelihoods):
243+
log_ps = np.array(
244+
[
245+
fit.log_p(data_position, model_position, sigma)
246+
for model_position in model_positions.array
247+
]
248+
)
249+
assert np.all(log_ps < -745.0)
250+
expected = log_ps.max() + np.log(np.sum(np.exp(log_ps - log_ps.max())))
251+
assert log_likelihood == pytest.approx(expected, rel=1.0e-12)
252+
253+
254+
def test__moderate_mismatch__log_sum_exp_matches_literal_form(data, noise_map):
255+
"""Where the literal `log(sum(exp(...)))` is finite, the shifted form must equal it."""
256+
257+
model_positions = al.Grid2DIrregular([(-1.0749, -1.1), (1.19117, 1.175)])
258+
259+
fit = al.FitPositionsImagePairAll(
260+
name="point_0",
261+
data=data,
262+
noise_map=noise_map,
263+
tracer=tracer,
264+
solver=al.mock.MockPointSolver(model_positions),
265+
)
266+
267+
for data_position, sigma, log_likelihood in zip(
268+
data, noise_map, fit.all_permutations_log_likelihoods()
269+
):
270+
literal = np.log(
271+
np.sum(
272+
np.exp(
273+
np.array(
274+
[
275+
fit.log_p(data_position, model_position, sigma)
276+
for model_position in model_positions.array
277+
]
278+
)
279+
)
280+
)
281+
)
282+
assert log_likelihood == pytest.approx(literal, rel=1.0e-14)
283+
284+
218285
def test__model_positions_present__chi_squared_unchanged(data, noise_map):
219286
"""The no-image branch must not perturb the ordinary path."""
220287

test_autolens/point/fit/positions/source/test_separations.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,115 @@ def test__fit_positions_source__multi_plane_tracer__model_data_traces_to_correct
8080
assert (fit_1.model_data == traced_grids[2]).all()
8181

8282

83+
class FitPositionsSourceJacobian(al.FitPositionsSource):
84+
weighting = "jacobian"
85+
86+
87+
def test__fit_positions_source__default_weighting_is_magnification():
88+
assert al.FitPositionsSource.weighting == "magnification"
89+
90+
91+
def test__fit_positions_source__jacobian_weighting__matches_solved_at_solved_centre():
92+
"""
93+
The free-centre tensor fit evaluated with its `centre` fixed at the solved centre `β*` must
94+
reproduce `FitPositionsSourceSolved`'s chi-squared and noise normalization exactly — the two
95+
likelihoods differ only by the solved class's analytic-marginalization term.
96+
"""
97+
galaxy_mass = al.Galaxy(
98+
redshift=0.5, mass=al.mp.IsothermalSph(centre=(0.0, 0.0), einstein_radius=0.1)
99+
)
100+
positions = al.Grid2DIrregular([(0.0, 1.0), (0.0, 2.0), (1.0, 0.0)])
101+
noise_map = al.ArrayIrregular([0.5, 1.0, 0.8])
102+
103+
tracer_solved = al.Tracer(
104+
galaxies=[
105+
galaxy_mass,
106+
al.Galaxy(redshift=1.0, point_0=al.ps.PointSolved()),
107+
]
108+
)
109+
fit_solved = al.FitPositionsSourceSolved(
110+
name="point_0",
111+
data=positions,
112+
noise_map=noise_map,
113+
tracer=tracer_solved,
114+
solver=None,
115+
)
116+
117+
beta_star = fit_solved.source_plane_coordinate
118+
119+
tracer_free = al.Tracer(
120+
galaxies=[
121+
galaxy_mass,
122+
al.Galaxy(redshift=1.0, point_0=al.ps.Point(centre=beta_star)),
123+
]
124+
)
125+
fit_free = FitPositionsSourceJacobian(
126+
name="point_0",
127+
data=positions,
128+
noise_map=noise_map,
129+
tracer=tracer_free,
130+
solver=None,
131+
)
132+
133+
assert fit_free.chi_squared_map.in_list == pytest.approx(
134+
fit_solved.chi_squared_map.in_list, rel=1.0e-8
135+
)
136+
assert fit_free.chi_squared == pytest.approx(fit_solved.chi_squared, rel=1.0e-8)
137+
assert fit_free.noise_normalization == pytest.approx(
138+
fit_solved.noise_normalization, rel=1.0e-8
139+
)
140+
assert fit_free.log_likelihood == pytest.approx(
141+
fit_solved.log_likelihood - fit_solved.marginalization_term, rel=1.0e-8
142+
)
143+
144+
145+
def test__fit_positions_source__jacobian_weighting__observed_plane_noise_normalization():
146+
galaxy_mass = al.Galaxy(
147+
redshift=0.5, mass=al.mp.IsothermalSph(centre=(0.0, 0.0), einstein_radius=0.1)
148+
)
149+
positions = al.Grid2DIrregular([(0.0, 1.0), (0.0, 2.0)])
150+
noise_map = al.ArrayIrregular([0.5, 1.0])
151+
152+
fit = FitPositionsSourceJacobian(
153+
name="point_0",
154+
data=positions,
155+
noise_map=noise_map,
156+
tracer=al.Tracer(
157+
galaxies=[
158+
galaxy_mass,
159+
al.Galaxy(redshift=1.0, point_0=al.ps.Point(centre=(0.0, 0.0))),
160+
]
161+
),
162+
solver=None,
163+
)
164+
165+
sigma_sq = noise_map.array**2.0
166+
assert fit.noise_normalization == pytest.approx(
167+
np.sum(np.log((2.0 * np.pi) ** 2.0 * sigma_sq**2.0)), rel=1.0e-12
168+
)
169+
170+
171+
def test__fit_positions_source__unknown_weighting_raises():
172+
class FitPositionsSourceTypo(al.FitPositionsSource):
173+
weighting = "magnificaton"
174+
175+
fit = FitPositionsSourceTypo(
176+
name="point_0",
177+
data=al.Grid2DIrregular([(0.0, 1.0)]),
178+
noise_map=al.ArrayIrregular([0.5]),
179+
tracer=al.Tracer(
180+
galaxies=[
181+
al.Galaxy(redshift=0.5),
182+
al.Galaxy(redshift=1.0, point_0=al.ps.Point(centre=(0.0, 0.0))),
183+
]
184+
),
185+
solver=None,
186+
)
187+
188+
with pytest.raises(al.exc.PointProfileMismatchException):
189+
fit.chi_squared_map
190+
191+
83192
def test__fit_positions_source_solved__source_plane_centre_matches_no_free_centre_prior():
84193
point_source = al.ps.PointSolved()
85194
galaxy_point_source = al.Galaxy(redshift=1.0, point_0=point_source)

0 commit comments

Comments
 (0)