Skip to content

Commit cb937d8

Browse files
authored
Merge pull request #685 from PyAutoLabs/feature/point-solver-padded-row-grads
fix: sanitize padded rows in the PointSolver implicit-diff rule for reverse mode
2 parents 27c9873 + a79f27b commit cb937d8

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

autolens/point/solver/implicit_diff.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@
3434
3535
Padded rows (the ``inf`` sentinels of the fixed ``MAX_CONTAINING_SIZE`` output) are
3636
constants of the output shape; their tangent is forced to zero so they cannot inject
37-
NaNs into the batch.
37+
NaNs into the batch. Because reverse mode transposes the rule into row-summed
38+
cotangents, masking the *output* alone is not enough: the padded rows' solve inputs
39+
are also sanitized (Jacobian evaluated at a real solved image, ``a_mat`` replaced by
40+
the identity, ``rhs`` zeroed) so no NaN ever enters the linear algebra. Evaluating
41+
the Jacobian at the padded rows' former ``(0, 0)`` placeholder was itself a NaN
42+
source — profile centres typically sit at the origin, where deflection Jacobians
43+
are singular (cluster host halos; PyAutoLens#678 phase B).
3844
3945
Known limitation — free cosmology parameters: ``Tracer`` is registered with
4046
``cosmology`` as ``no_flatten`` aux, so a cosmology carrying traced parameters (a free
@@ -83,7 +89,15 @@ def implicit_tangents_from(jac_alpha, dalpha, dbeta, finite, xp):
8389
"""
8490
identity = xp.eye(2)
8591
a_mat = identity[None, :, :] - jac_alpha
92+
# Sanitize padded rows before the solve: a non-finite padded ``a_mat`` row
93+
# survives the output masking in forward mode but not in reverse mode,
94+
# where the transpose solves against ``a_mat`` row-by-row and sums the
95+
# cotangents — one NaN padded row contaminates every parameter's gradient.
96+
# Real (finite) rows are untouched, so legitimate near-critical divergence
97+
# is still surfaced.
98+
a_mat = xp.where(finite[:, None, None], a_mat, identity[None, :, :])
8699
rhs = dalpha + dbeta[None, :]
100+
rhs = xp.where(finite[:, None], rhs, 0.0)
87101
dtheta = xp.linalg.solve(a_mat, rhs[..., None])[..., 0]
88102
return xp.where(finite[:, None], dtheta, 0.0)
89103

@@ -171,7 +185,17 @@ def solve_padded_jvp(primals, tangents):
171185

172186
theta = solve_padded(tracer, beta)
173187
finite = xp.isfinite(theta).all(axis=1)
174-
theta_safe = xp.where(finite[:, None], theta, 0.0)
188+
# Padded rows are anchored at the first real solved image rather than
189+
# (0, 0): profile centres typically sit at the origin, where deflection
190+
# Jacobians are singular (an NFW's jacfwd at its own centre is NaN),
191+
# and although the padded rows' tangents are masked below, reverse mode
192+
# transposes the rule into row-summed cotangents — one NaN row poisons
193+
# the gradient of every parameter. At a real image the Jacobian is
194+
# finite and the padded rows' contributions are exactly zeroed. With
195+
# zero solved images the anchor row is itself non-finite and the
196+
# gradient is NaN — the likelihood is already invalid there.
197+
anchor = theta[xp.argmax(finite)]
198+
theta_safe = xp.where(finite[:, None], theta, anchor[None, :])
175199

176200
def deflections_single(position, tracer_):
177201
return deflections_from(position[None, :], tracer_)[0]

test_autolens/point/triangles/test_implicit_diff.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ def test_implicit_tangents_solve_the_linear_system():
3737
np.testing.assert_array_equal(dtheta[i], 0.0)
3838

3939

40+
def test_implicit_tangents_padded_nan_rows_never_reach_the_solve():
41+
"""
42+
Padded rows carry whatever the Jacobian evaluated at their placeholder position
43+
produced — NaN when that position sits on a profile centre. The rule must
44+
sanitize those rows before the solve (identity ``a_mat``, zero ``rhs``): in
45+
reverse mode the transpose solves against ``a_mat`` row-by-row and sums the
46+
cotangents, so a NaN padded row would contaminate every parameter's gradient
47+
even though the forward output masks it (#678 phase B, cluster cells).
48+
"""
49+
jac_alpha = np.array(
50+
[
51+
[[0.5, 0.0], [0.0, 0.5]],
52+
[[np.nan, np.nan], [np.nan, np.nan]],
53+
]
54+
)
55+
dalpha = np.array([[1.0, 2.0], [np.nan, np.nan]])
56+
dbeta = np.array([0.1, -0.2])
57+
finite = np.array([True, False])
58+
59+
dtheta = implicit_diff.implicit_tangents_from(
60+
jac_alpha=jac_alpha, dalpha=dalpha, dbeta=dbeta, finite=finite, xp=np
61+
)
62+
63+
assert np.isfinite(dtheta).all()
64+
np.testing.assert_allclose(dtheta[0], (dalpha[0] + dbeta) / 0.5, rtol=1e-12)
65+
np.testing.assert_array_equal(dtheta[1], 0.0)
66+
67+
4068
def test_implicit_tangents_near_critical_diverge_unclamped():
4169
# det(I - J) -> 0: the tangent must diverge with the true solve, never be clamped.
4270
eps = 1e-12

0 commit comments

Comments
 (0)