|
| 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