Skip to content

Commit dd2cd72

Browse files
Claudeclaude
andcommitted
test: JAX gate for the kernel term shortcut and jitter conventions
Closes the JAX leg of this work's gate at library level. The workspace script `autolens_workspace_test/scripts/imaging/jax_grad/regularization.py` certifies the same surface end-to-end, but it lives in another repo and needs the whole autolens stack; these cover the code this branch actually adds. Measured: FD certification d(s^T C^-1 s)/d(scale) rel diff 4.6e-09 eager vs jit 3.6e-15 implicit vs explicit inverse (JAX) 1.1e-13 numpy vs jax parity 8.4e-12 Covers both jitter conventions under `jit` (the `jitter_relative` branch must resolve at trace time, not on a tracer), gradients through the scheme-level `regularization_term_from` hook, and FD certification with relative jitter on. Matern is deliberately not covered: its JAX path needs the modified Bessel from `tfp-nightly`, an optional-of-an-optional. Gaussian and Exponential exercise the same shared `apply_jitter` / `quadratic_form_via_cholesky` code without it. Skipped via importorskip when JAX is absent. Full suite with JAX installed: 936 passed, 52 skipped (3 pre-existing pynufft failures unrelated, missing optional dep). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KazMzMZYPLfaZoYQ79YQ8Q
1 parent 6381b7f commit dd2cd72

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""
2+
JAX leg of the gate for the kernel-scheme linear-algebra work: the
3+
`quadratic_form_via_cholesky` term shortcut and the `apply_jitter` conventions must be
4+
`jit`-safe, differentiable, and agree with the NumPy path.
5+
6+
These mirror, at library level, what
7+
`autolens_workspace_test/scripts/imaging/jax_grad/regularization.py` certifies at
8+
workspace level. Skipped when JAX is absent (it is an optional dependency).
9+
10+
The Matern kernel is deliberately not covered here: its JAX path needs the modified
11+
Bessel `K_nu` from `tfp-nightly`, which is an optional-of-an-optional. The Gaussian and
12+
Exponential kernels exercise the same shared code (`apply_jitter`,
13+
`quadratic_form_via_cholesky`) without it.
14+
"""
15+
16+
import numpy as np
17+
import pytest
18+
19+
jax = pytest.importorskip("jax")
20+
jnp = pytest.importorskip("jax.numpy")
21+
22+
jax.config.update("jax_enable_x64", True)
23+
24+
from autoarray.inversion.regularization.matern_kernel import ( # noqa: E402
25+
apply_jitter,
26+
inv_via_cholesky,
27+
quadratic_form_via_cholesky,
28+
)
29+
from autoarray.inversion.regularization.gaussian_kernel import ( # noqa: E402
30+
gauss_cov_matrix_from,
31+
)
32+
from autoarray.inversion.regularization.exponential_kernel import ( # noqa: E402
33+
exp_cov_matrix_from,
34+
)
35+
36+
POINTS = np.random.default_rng(0).normal(size=(10, 2))
37+
VECTOR = np.random.default_rng(1).normal(size=10)
38+
39+
40+
@pytest.mark.parametrize("jitter_relative", [False, True])
41+
@pytest.mark.parametrize("cov_from", [gauss_cov_matrix_from, exp_cov_matrix_from])
42+
def test__apply_jitter__is_jit_safe_in_both_conventions(jitter_relative, cov_from):
43+
"""
44+
`jitter_relative` is a static Python bool, so the branch inside `apply_jitter` must
45+
resolve at trace time rather than on a tracer.
46+
"""
47+
48+
@jax.jit
49+
def build(points):
50+
covariance = cov_from(scale=1.0, pixel_points=points, jitter=0.0, xp=jnp)
51+
return apply_jitter(
52+
covariance, jitter=1e-8, jitter_relative=jitter_relative, xp=jnp
53+
)
54+
55+
covariance = build(jnp.asarray(POINTS))
56+
57+
assert covariance.shape == (POINTS.shape[0], POINTS.shape[0])
58+
assert bool(jnp.all(jnp.isfinite(covariance)))
59+
60+
61+
@pytest.mark.parametrize("cov_from", [gauss_cov_matrix_from, exp_cov_matrix_from])
62+
def test__quadratic_form_via_cholesky__gradient_is_finite_difference_certified(
63+
cov_from,
64+
):
65+
"""
66+
The certification the workspace `jax_grad/regularization.py` script exists to give:
67+
autodiff through the Cholesky solve must match a central finite difference.
68+
"""
69+
70+
def term(scale):
71+
covariance = cov_from(
72+
scale=scale, pixel_points=jnp.asarray(POINTS), jitter=1e-8, xp=jnp
73+
)
74+
return quadratic_form_via_cholesky(covariance, jnp.asarray(VECTOR), xp=jnp)
75+
76+
autodiff = float(jax.grad(term)(1.3))
77+
78+
step = 1.0e-6
79+
finite_difference = float((term(1.3 + step) - term(1.3 - step)) / (2.0 * step))
80+
81+
assert autodiff == pytest.approx(finite_difference, rel=1.0e-6)
82+
83+
84+
@pytest.mark.parametrize("cov_from", [gauss_cov_matrix_from, exp_cov_matrix_from])
85+
def test__quadratic_form_via_cholesky__eager_and_jit_agree(cov_from):
86+
def term(scale):
87+
covariance = cov_from(
88+
scale=scale, pixel_points=jnp.asarray(POINTS), jitter=1e-8, xp=jnp
89+
)
90+
return quadratic_form_via_cholesky(covariance, jnp.asarray(VECTOR), xp=jnp)
91+
92+
assert float(jax.jit(term)(1.3)) == pytest.approx(float(term(1.3)), rel=1.0e-12)
93+
94+
95+
@pytest.mark.parametrize("cov_from", [gauss_cov_matrix_from, exp_cov_matrix_from])
96+
def test__quadratic_form_via_cholesky__matches_explicit_inverse_and_numpy(cov_from):
97+
"""
98+
The shortcut must be the same quantity as the explicit-inverse contraction it
99+
replaces, on both backends.
100+
"""
101+
covariance_jax = cov_from(
102+
scale=1.3, pixel_points=jnp.asarray(POINTS), jitter=1e-8, xp=jnp
103+
)
104+
covariance_numpy = cov_from(scale=1.3, pixel_points=POINTS, jitter=1e-8)
105+
106+
implicit = float(
107+
quadratic_form_via_cholesky(covariance_jax, jnp.asarray(VECTOR), xp=jnp)
108+
)
109+
explicit = float(
110+
jnp.asarray(VECTOR)
111+
@ (inv_via_cholesky(covariance_jax, xp=jnp) @ jnp.asarray(VECTOR))
112+
)
113+
numpy_implicit = float(quadratic_form_via_cholesky(covariance_numpy, VECTOR))
114+
115+
assert implicit == pytest.approx(explicit, rel=1.0e-9)
116+
assert implicit == pytest.approx(numpy_implicit, rel=1.0e-9)
117+
118+
119+
def test__regularization_term_from__is_differentiable_end_to_end_under_jax():
120+
"""
121+
The scheme-level hook, not just the helper: gradients must reach the mesh grid the
122+
term is built from.
123+
"""
124+
import autoarray as aa
125+
126+
regularization = aa.reg.GaussianKernel(coefficient=3.0, scale=1.0)
127+
128+
class _Obj:
129+
def __init__(self, array):
130+
self.source_plane_mesh_grid = type("_G", (), {"array": array})()
131+
132+
def term(points):
133+
return regularization.regularization_term_from(
134+
linear_obj=_Obj(points), reconstruction=jnp.asarray(VECTOR), xp=jnp
135+
)
136+
137+
value = float(term(jnp.asarray(POINTS)))
138+
gradient = jax.grad(term)(jnp.asarray(POINTS))
139+
140+
assert np.isfinite(value)
141+
assert bool(jnp.all(jnp.isfinite(gradient)))
142+
assert float(jnp.linalg.norm(gradient)) > 0.0
143+
144+
145+
@pytest.mark.parametrize("jitter_relative", [False, True])
146+
def test__relative_jitter__does_not_break_gradients(jitter_relative):
147+
def term(scale):
148+
covariance = gauss_cov_matrix_from(
149+
scale=scale,
150+
pixel_points=jnp.asarray(POINTS),
151+
jitter=1e-8,
152+
jitter_relative=jitter_relative,
153+
xp=jnp,
154+
)
155+
return quadratic_form_via_cholesky(covariance, jnp.asarray(VECTOR), xp=jnp)
156+
157+
autodiff = float(jax.grad(term)(1.3))
158+
159+
step = 1.0e-6
160+
finite_difference = float((term(1.3 + step) - term(1.3 - step)) / (2.0 * step))
161+
162+
assert np.isfinite(autodiff)
163+
assert autodiff == pytest.approx(finite_difference, rel=1.0e-6)

0 commit comments

Comments
 (0)