|
34 | 34 |
|
35 | 35 | Padded rows (the ``inf`` sentinels of the fixed ``MAX_CONTAINING_SIZE`` output) are |
36 | 36 | 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). |
38 | 44 |
|
39 | 45 | Known limitation — free cosmology parameters: ``Tracer`` is registered with |
40 | 46 | ``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): |
83 | 89 | """ |
84 | 90 | identity = xp.eye(2) |
85 | 91 | 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, :, :]) |
86 | 99 | rhs = dalpha + dbeta[None, :] |
| 100 | + rhs = xp.where(finite[:, None], rhs, 0.0) |
87 | 101 | dtheta = xp.linalg.solve(a_mat, rhs[..., None])[..., 0] |
88 | 102 | return xp.where(finite[:, None], dtheta, 0.0) |
89 | 103 |
|
@@ -171,7 +185,17 @@ def solve_padded_jvp(primals, tangents): |
171 | 185 |
|
172 | 186 | theta = solve_padded(tracer, beta) |
173 | 187 | 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, :]) |
175 | 199 |
|
176 | 200 | def deflections_single(position, tracer_): |
177 | 201 | return deflections_from(position[None, :], tracer_)[0] |
|
0 commit comments