Skip to content

fix: wrong JAX gradient through rectangular mesh (RTU all backends, Bilinear CPU sparse) #671

Closed

Description

@Jammy2211

Overview

On the imaging/rectangular/hst fiducial cell (39x39, no --standard-size), the JAX gradient through the rectangular source mesh is wrong by many orders of magnitude while log L is correct to 1e-10. RTU (kernel-CDF) is wrong on both CPU sparse and A100 dense, and the two backends disagree in sign. Bilinear (rank-CDF) is right on A100 dense but wrong on CPU sparse. Gradient-based samplers on these meshes get garbage gradients. Found in autolens_profiling#509 (likelihood-lifecycle 6c).

Plan

  • Write a small CPU test that reproduces the bug on a mock rectangular cell. It compares jax.value_and_grad with a central-difference sweep, for RTU on dense and sparse, and Bilinear on sparse. That separates the two suspected bugs before any fix.
  • Work out which parameters carry the huge gradient components. Then step through the mesh code in order (CDF transform, knot inverse, interpolation weights, mapping/curvature) and check the gradient after each step.
  • Fix each cause in PyAutoArray without changing any log L value: same values to ≤1e-10 on the existing tests.
  • Check against the prompt's Witness on the real 39x39 HST fiducial cell, CPU sparse and A100 dense, by running gradient_breakdown.py read-only from the p6c autolens_profiling checkout (no autolens_profiling changes in this task).
  • Tier: judge. Merge mode: human /prm.
Detailed implementation plan

Affected Repositories

  • PyAutoArray (primary, only claimed repo)
  • autolens_profiling: used read-only for the Witness via the p6c worktree (claimed by likelihood-lifecycle-p6c-gradient-walls). Any profiling-side change goes onto p6c or a follow-up.

Branch Survey

Repository Current Branch Dirty?
./array/PyAutoArray main (c7da8b0) clean

Suggested branch: feature/rectangular-gradient-rtu-sparse
Worktree: ~/Code/PyAutoLabs-wt/rectangular-gradient-rtu-sparse/

Implementation Steps

  1. Repro tests (test_autoarray/inversion/): gradient vs FD plateau (steps 1e-4..1e-7, seeded directions) for RTU dense, RTU sparse and Bilinear sparse; dense vs sparse gradient agreement. CPU, fp64. Cover both the fiducial-like and standard-size meshes, because the 74 standard-size control legs agree.
  2. Suspect 1, RTU on all backends. It's in autoarray/inversion/mesh/interpolator/rectangular.py. create_transforms builds inv_transform = reverse_interp(F_knots, knots). Where a knot sits in the empty tails, consecutive F_knots differ by about 1e-12. The gradient of jnp.interp with respect to its xp argument (the F_knots table) is roughly 1/ΔF there, which would give |g| ≈ 1e9. That inverse feeds source_plane_mesh_grid and the node centres, so it reaches the regularization even though the forward mapping is smooth. Other things to check: the min/max/std().min() normalisation and the xp.clip at saturation. Likely fix: a ΔF floor or a monotone reparametrisation so the knot inverse is smooth at the tails, or stop the gradient through a table that doesn't affect the value.
  3. Suspect 2, CPU sparse only. Bilinear is right on dense and wrong on sparse. The suspect is the sparse curvature and mapped-data path: inversion/imaging/sparse.py, sparse_operator.curvature_matrix_diag_from / curvature_matrix_off_diag_from, and inversion_imaging_util.mapped_reconstructed_operated_data_via_sparse_operator_from. Look for scatter, segment-sum or gather derivatives that drop or double-count the weight terms, and compare against dense at matched values.
  4. Fix, re-run the value tests (log L unchanged), then run the Witness on the HST fiducial (CPU sparse locally or on RAL, A100 dense on RAL with a human go).

Key Files

  • autoarray/inversion/mesh/interpolator/rectangular.py: kernel and rank CDF transforms, knot inverse, bilinear weights
  • autoarray/inversion/mesh/mesh/rectangular_rtu_adapt_density.py / rectangular_rtu_adapt_image.py
  • autoarray/inversion/inversion/imaging/sparse.py, inversion_imaging_util.py: sparse curvature and mapped data

Evidence

  • ~/Science/Science/scratch/delaunay_hilbert_2026-11-01/rtu_vs_bilinear/ (gradient.json per leg) and results/rectangular_hst_rtu_fiducial/probe.json + README.md
  • RAL 413867–413870; /mnt/ral/jnightin/profiling/autolens_profiling_p2dg/output/p6_gradient_rtu/

Original Prompt

Click to expand starting prompt

Wrong JAX gradient through the rectangular source mesh: RTU (all backends) and Bilinear on the CPU sparse path

Type: bug
Target: PyAutoArray
Repos:

  • PyAutoArray
  • autolens_profiling
    Themes:
  • jax-gradient
    Difficulty: medium
    Autonomy: supervised
    Priority: high
    Status: formalised
    Consequence: judge
    Witness: on imaging/rectangular/hst fiducial (39x39, no --standard-size) value_and_grad agrees with the central-difference plateau (steps 1e-4..1e-7, 3 seeded directions) to <= 1e-3 relative for RectangularRTU on CPU sparse and A100 dense and for Bilinear on CPU sparse, and CPU/A100 gradients agree
    Review-minutes: 10
    Unattended: never
    Filed: 2026-11-02

Found during likelihood-lifecycle phase 6c (autolens_profiling#509), RTU vs Bilinear side run. Libraries at tag 2026.10.25.1. Human: "yes" (file it), 2026-11-02.

Symptom

Cell imaging/rectangular/hst, fiducial 39x39 (no --standard-size), campaign leg-11 flags, gradient_breakdown.py --gradient, mesh via --rect-mesh.

  • RTU (kernel-CDF): the likelihood is smooth. Along the 3 seeded unit directions the central difference plateaus at about -2895 / 1237 / 2530 over steps 1e-4..1e-7. Autodiff gives 5.6e7 / -1.6e8 / 5.6e7 on A100 dense (RAL 413870) and -1.8e8 / 5.9e7 / 7.1e7 on CPU sparse (RAL 413868), with |g| ≈ 2e9. The two backends differ in sign, while log L agrees to 1e-10.
  • Bilinear (rank-CDF): A100 dense gives |g| 3.3e4, consistent with the FD at h=1e-3 (413869). CPU sparse gives |g| 6.5e10 and an analytic directional derivative of -2.4e9 against an FD of -4386 (413867).
  • Control: on the 74 standard-size v2 campaign legs (RAL 413791-413830), CPU and A100 |g| are identical on every cell, including imaging/rectangular at all 4 instruments.

The value is right and only the gradient is wrong, so the bug is in the derivative path: a near-singular term (sqrt / division / where guard at a degenerate point) in the RTU kernel CDF, or in the sparse mapping-matrix construction that the CPU path uses. Which one is open.

Evidence

  • ~/Science/Science/scratch/delaunay_hilbert_2026-11-01/rtu_vs_bilinear/{rtu,bilinear}/results/imaging/rectangular/hst/{jax_cpu_sparse_fp64/hpc_ral_cpu,jax_gpu_dense_fp64/hpc_a100}/gradient.json
  • ~/Science/Science/scratch/delaunay_hilbert_2026-11-01/results/rectangular_hst_rtu_fiducial/probe.json (13-step FD sweep + log L slices, A100); write-up in that folder's README.md.
  • RAL: /mnt/ral/jnightin/profiling/autolens_profiling_p2dg/output/p6_gradient_rtu/.

Suggested first step

Reduce to a CPU-reproducible unit test: per-parameter gradient vs FD on the fiducial cell to find which parameters carry the huge components (mesh weights, lens mass, or lens light), then bisect the RTU / sparse mapper path with jax.debug / jax.grad on intermediate quantities.

Activity

  1. Jammy2211 commented on Nov 2, 2026

    @Jammy2211
    CollaboratorAuthor

    Diagnosis: the 1e9 gradient is the evaluation point, not the mesh

    Reproduced locally on the real imaging/rectangular/hst fiducial (RTU dense, MGE-60, 39x39, prior medians): |g| = 2.77e9 (RAL: 1.97e9). All of the error is in mass.centre_0 and mass.centre_1: autodiff 1.8e9 / 2.1e9 against FD −944 / −380. Every other parameter agrees with FD.

    Mechanism:

    • The HST data is 141x141 (odd), so at pixelization over-sampling 1 there is a source-grid point at exactly (0,0).
    • At the prior medians the Isothermal centre is also exactly (0,0).
    • psi_from in autogalaxy/profiles/mass/total/isothermal.py softens the core as sqrt(q²x² + y² + 1e-16), a core of radius 1e-8. At that grid point, d(deflection)/d(centre) ≈ −1.5e8, checked directly.
    • Autodiff is exact for the softened function. The FD steps (1e-4 to 1e-7) are far wider than the core and cannot resolve it. A gradient that depends on a 1e-8 core is also rounding-sensitive, which fits the CPU/A100 sign flip.

    Control: with the centre prior means nudged by 1e-5″, |g| drops to 2.4e5 and the mass-centre components fall back to the FD scale (−1681 vs −1274, −2028 vs −1026; the remaining gap is that point now sitting about 1.4e-5″ from the core).

    Re-scope (human, 2026-11-02): this issue now targets the remaining real error. With MGE-60 lens light, autodiff and FD disagree by 2–5% on the Einstein radius, ellipticity and shear. This also happens on the even-grid jax_test dataset, where no grid point is near the centre, and dense and sparse agree exactly (so it is not the sparse path). The fiducial evaluation point is a profiling-side fix, noted on autolens_profiling#509.

    Also seen, not yet chased: with a non-linear Sersic lens light, the sparse path's log L differs greatly from dense (−52010 vs −933, with the warning "No blurring_image provided").

    Repro + results: Science/scratch/rectangular_gradient_rtu_sparse_2026-11-02/ (repro.py; env vars REAL, MGE, MEDIANS, NUDGE).

  2. Jammy2211 commented on Nov 2, 2026

    @Jammy2211
    CollaboratorAuthor

    Library PR created

    #672 (pending-release)

    Root cause of the 2–5% error: ellipse_params_via_border_pca_from took the border-ellipse semi-axes as max(|x′|) / max(|y′|). A lens centred on the mask's symmetry centre (as at the prior medians) traces the border into exact ± pairs, so both maxima tie. JAX's tie-sharing max gradient is unstable under jit: the reverse-mode gradient of b came out 2× or 0.5× its true value, while eager mode was correct. MGE was a red herring; the error only showed through the jitted Fitness path.

    Fix: a new helper, max_at_argmax_from, takes the element at the argmax. Values are unchanged, and the gradient is one-hot under eager and jit.

    Verification:

    • On the jitted repro, mass.einstein_radius now matches FD (24.183 vs 24.183, previously 25.46).
    • The full test_autoarray suite passes (2268 passed).
    • New regression test added.

    Remaining:

    • The HST-fiducial Witness depends on autolens_profiling#509 moving the evaluation point off the grid.
    • Owed at close-out: assess the impact on the gradient campaign. Candidate cells are the jitted gradient cells evaluated with the lens on the mask's symmetry centre with border relocation on, for any pixelized mesh.
  3. Jammy2211 commented on Nov 2, 2026

    @Jammy2211
    CollaboratorAuthor

    Shipped

    • fix: tie-robust border-ellipse extents so jit gradients are correct #672 merged (d7b642e); pending release.
    • Fix: max_at_argmax_from makes the border-ellipse extents tie-robust, so reverse-mode gradients under jit are correct when the lens sits on the mask's symmetry centre.
    • The |g| ≈ 2e9 on the HST fiducial is the Isothermal core at a grid-point centre, not a library bug. The evaluation-point fix belongs to autolens_profiling#509.
    • Follow-ups filed in PyAutoMind: assess the impact on the gradient campaign (human request), and check the sparse vs dense log L mismatch with a non-linear lens light.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions