Skip to content

Commit 42b0851

Browse files
authored
Merge pull request #591 from PyAutoLabs/feature/weak-sigma-crit-jax
feat: FitWeak per-galaxy sigma_crit scaling + JAX support (weak series step 10)
2 parents 03a54e9 + dba5da4 commit 42b0851

3 files changed

Lines changed: 235 additions & 29 deletions

File tree

autolens/weak/fit.py

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,31 @@
44
``FitWeak`` compares a model shear field (derived from a ``Tracer``'s mass profiles via
55
``LensCalc.shear_yx_2d_via_hessian_from`` — the same primitive ``SimulatorShearYX`` uses) against an observed
66
``WeakDataset`` and reports per-galaxy residuals, chi-squared and the log-likelihood. It is the weak-lensing
7-
analogue of :class:`autolens.imaging.fit_imaging.FitImaging` and the input to a future ``AnalysisWeak``.
7+
analogue of :class:`autolens.imaging.fit_imaging.FitImaging` and the input to ``AnalysisWeak``.
88
99
Each background source galaxy contributes **two** independent measurements (:math:`\\gamma_1` and
1010
:math:`\\gamma_2` carry the same per-galaxy noise but are independent Gaussian draws), so the chi-squared sum
1111
and ``noise_normalization`` count :math:`N \\times 2` elements rather than just :math:`N`.
1212
13+
The model quantity adapts to what the dataset declares:
14+
15+
- ``dataset.is_reduced`` — the model is the *reduced* shear :math:`g = \\gamma / (1 - \\kappa)`, the quantity
16+
real surveys measure from galaxy ellipticities.
17+
- ``dataset.redshifts`` — the model signal is scaled per galaxy by the lensing-efficiency ratio
18+
:math:`\\beta_i / \\beta_{\\rm ref}` (``LensingCosmology.scaling_factor_between_redshifts_from``), so a
19+
catalogue spanning a range of source redshifts is fitted self-consistently. The reference plane is the
20+
tracer's outermost (source) plane; galaxies at or below the lens redshift carry zero signal. Without
21+
redshifts the fit assumes the tracer's single effective source plane — exactly the pre-scaling behaviour.
22+
The scale factors are computed eagerly from concrete plane redshifts (galaxy redshifts are fixed
23+
constants, not sampled parameters), which keeps them outside any JAX trace.
24+
1325
The class is deliberately standalone — it does not inherit from ``autoarray.fit.fit_dataset.AbstractFit``,
1426
which is shaped for "data + noise_map + mask" pixel-grid fits. ``FitPoint`` (in ``autolens.point``) follows
1527
the same standalone pattern.
28+
29+
JAX support follows the ``LensCalc`` guard pattern: with ``xp=jnp`` the fit statistics are traceable and
30+
``model_shear`` returns a raw ``(N, 2)`` array (``ShearYX2DIrregular`` is not a registered pytree);
31+
``AnalysisWeak`` registers the ``FitWeak`` pytree so ``jax.jit(fit_from)`` round-trips a real fit object.
1632
"""
1733
import math
1834
from functools import cached_property
@@ -26,7 +42,7 @@
2642

2743

2844
class FitWeak:
29-
def __init__(self, dataset: WeakDataset, tracer):
45+
def __init__(self, dataset: WeakDataset, tracer, xp=np):
3046
"""
3147
Fit a ``Tracer`` lens model to a ``WeakDataset`` shear catalogue.
3248
@@ -36,71 +52,140 @@ def __init__(self, dataset: WeakDataset, tracer):
3652
The observed weak-lensing shear catalogue.
3753
tracer
3854
The PyAutoLens ``Tracer`` whose mass profiles generate the model shear field.
55+
xp
56+
The array module (``numpy`` or ``jax.numpy``). With ``jax.numpy`` the fit statistics are
57+
traceable and ``model_shear`` returns a raw array rather than a ``ShearYX2DIrregular``.
3958
"""
4059
self.dataset = dataset
4160
self.tracer = tracer
61+
self._xp = xp
4262

4363
@cached_property
44-
def model_shear(self) -> ShearYX2DIrregular:
64+
def _redshift_scale_factors(self):
65+
"""
66+
Per-galaxy lensing-efficiency ratios ``beta_i / beta_ref``, or ``None`` when the dataset carries no
67+
redshifts.
68+
69+
Unity for galaxies at the tracer's source-plane redshift, zero at or below the lens redshift (such
70+
galaxies are not lensed), and ``LensingCosmology.scaling_factor_between_redshifts_from`` in between —
71+
the same factor multi-plane ray-tracing applies to deflections. Always a concrete NumPy array:
72+
plane and catalogue redshifts are fixed constants, so this never participates in a JAX trace.
4573
"""
46-
The model shear field evaluated at the galaxy positions, via ``LensCalc``.
74+
redshifts = getattr(self.dataset, "redshifts", None)
75+
if redshifts is None:
76+
return None
77+
78+
plane_redshifts = sorted(
79+
float(galaxy.redshift) for galaxy in self.tracer.galaxies
80+
)
81+
redshift_lens = plane_redshifts[0]
82+
redshift_ref = plane_redshifts[-1]
83+
84+
cosmology = self.tracer.cosmology
85+
86+
factors = [
87+
0.0
88+
if float(redshift_i) <= redshift_lens
89+
else float(
90+
cosmology.scaling_factor_between_redshifts_from(
91+
redshift_0=redshift_lens,
92+
redshift_1=float(redshift_i),
93+
redshift_final=redshift_ref,
94+
)
95+
)
96+
for redshift_i in np.asarray(redshifts)
97+
]
98+
return np.asarray(factors)
4799

48-
When the dataset is marked ``is_reduced`` (real catalogues measure galaxy ellipticities,
49-
i.e. the reduced shear) the model quantity is ``g = gamma / (1 - kappa)``, with the
50-
convergence from the same Hessian primitive — so data and model always live in the same
51-
space.
100+
@cached_property
101+
def model_shear(self):
52102
"""
103+
The model signal evaluated at the galaxy positions, via ``LensCalc``.
104+
105+
This is the (optionally per-galaxy-scaled) shear ``gamma``, or the reduced shear
106+
``g = gamma / (1 - kappa)`` when the dataset is marked ``is_reduced`` — data and model always live
107+
in the same space. On the NumPy path the return is a ``ShearYX2DIrregular``; with ``xp=jax.numpy``
108+
it is a raw ``(N, 2)`` array (the ``LensCalc`` guard pattern).
109+
"""
110+
xp = self._xp
111+
53112
lens_calc = LensCalc.from_tracer(self.tracer)
54113

55-
shear = lens_calc.shear_yx_2d_via_hessian_from(grid=self.dataset.positions)
114+
shear = lens_calc.shear_yx_2d_via_hessian_from(
115+
grid=self.dataset.positions, xp=xp
116+
)
56117

57-
if not getattr(self.dataset, "is_reduced", False):
118+
scale = self._redshift_scale_factors
119+
is_reduced = getattr(self.dataset, "is_reduced", False)
120+
121+
if scale is None and not is_reduced:
58122
return shear
59123

60-
convergence = lens_calc.convergence_2d_via_hessian_from(
61-
grid=self.dataset.positions
62-
)
63-
return ShearYX2DIrregular(
64-
values=np.asarray(shear) / (1.0 - np.asarray(convergence))[:, None],
65-
grid=self.dataset.positions,
66-
)
124+
values = xp.asarray(shear)
125+
126+
if is_reduced:
127+
convergence = xp.asarray(
128+
lens_calc.convergence_2d_via_hessian_from(
129+
grid=self.dataset.positions, xp=xp
130+
)
131+
)
132+
if scale is not None:
133+
values = (scale[:, None] * values) / (
134+
1.0 - scale * convergence
135+
)[:, None]
136+
else:
137+
values = values / (1.0 - convergence)[:, None]
138+
else:
139+
values = scale[:, None] * values
140+
141+
if xp is np:
142+
return ShearYX2DIrregular(
143+
values=np.asarray(values), grid=self.dataset.positions
144+
)
145+
return values
67146

68147
@property
69-
def residual_map(self) -> np.ndarray:
148+
def residual_map(self):
70149
"""``(N, 2)`` residuals ``data - model`` for each galaxy's ``(gamma_2, gamma_1)`` components."""
71-
return np.asarray(self.dataset.shear_yx) - np.asarray(self.model_shear)
150+
xp = self._xp
151+
data = xp.asarray(np.asarray(self.dataset.shear_yx))
152+
return data - xp.asarray(self.model_shear)
72153

73154
@property
74-
def normalized_residual_map(self) -> np.ndarray:
155+
def normalized_residual_map(self):
75156
"""``(N, 2)`` residuals divided by the per-galaxy noise broadcast across both shear components."""
76-
noise = np.asarray(self.dataset.noise_map)[:, None]
157+
xp = self._xp
158+
noise = xp.asarray(np.asarray(self.dataset.noise_map))[:, None]
77159
return self.residual_map / noise
78160

79161
@property
80-
def chi_squared_map(self) -> np.ndarray:
162+
def chi_squared_map(self):
81163
"""``(N, 2)`` per-component chi-squared contributions."""
82164
return self.normalized_residual_map**2
83165

84166
@property
85-
def chi_squared(self) -> float:
167+
def chi_squared(self):
86168
"""Scalar chi-squared summed over all ``N x 2`` shear measurements."""
87-
return float(np.sum(self.chi_squared_map))
169+
xp = self._xp
170+
chi_squared = xp.sum(self.chi_squared_map)
171+
return float(chi_squared) if xp is np else chi_squared
88172

89173
@property
90174
def noise_normalization(self) -> float:
91175
r"""
92176
Gaussian likelihood normalisation :math:`\sum \log(2 \pi \sigma^2)` summed over all ``N x 2`` shear
93177
measurements — the factor of 2 reflects that each galaxy contributes two independent components.
178+
Always concrete (it depends only on the dataset).
94179
"""
95180
noise = np.asarray(self.dataset.noise_map)
96181
return float(2.0 * np.sum(np.log(2.0 * math.pi * noise**2)))
97182

98183
@property
99-
def log_likelihood(self) -> float:
184+
def log_likelihood(self):
100185
r"""Standard Gaussian log-likelihood :math:`-\tfrac{1}{2}(\chi^2 + \text{noise normalization})`."""
101186
return -0.5 * (self.chi_squared + self.noise_normalization)
102187

103188
@property
104-
def figure_of_merit(self) -> float:
189+
def figure_of_merit(self):
105190
"""Quantity returned to non-linear searches; same as ``log_likelihood`` (no inversion / evidence)."""
106191
return self.log_likelihood

autolens/weak/model/analysis.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ def __init__(
5151
contributes two independent shear measurements (gamma_1 and gamma_2), which `FitWeak` compares against
5252
the model shear field of the `Tracer`.
5353
54-
`use_jax` defaults to `False` because `FitWeak` is a NumPy-only fit (its `model_shear` is cached via
55-
`functools.cached_property` and its statistics use `np.asarray`); JAX support requires pytree
56-
registration of `FitWeak` and an `xp`-threaded fit path, which is deliberate future work.
54+
`use_jax` defaults to `False`, the conservative choice for the newest Analysis class; pass
55+
`use_jax=True` to run the `xp`-threaded fit path with `FitWeak` pytree registration (validated by
56+
the `autolens_workspace_test` weak vmap-parity script).
5757
5858
Parameters
5959
----------
@@ -122,14 +122,37 @@ def fit_from(self, instance) -> FitWeak:
122122
-------
123123
The fit of the lens model to the weak-lensing shear catalogue.
124124
"""
125+
if self._use_jax:
126+
self._register_fit_weak_pytrees()
127+
125128
tracer = self.tracer_via_instance_from(
126129
instance=instance,
127130
)
128131

129132
return FitWeak(
130133
dataset=self.dataset,
131134
tracer=tracer,
135+
xp=self._xp,
136+
)
137+
138+
@staticmethod
139+
def _register_fit_weak_pytrees() -> None:
140+
"""Register every type reachable from a ``FitWeak`` return value so
141+
``jax.jit(fit_from)`` can flatten its output.
142+
143+
``dataset`` and ``_xp`` are constants per analysis — ride as aux so JAX does
144+
not recurse into them (the cached ``_redshift_scale_factors`` derives purely
145+
from the dataset and plane redshifts, so it stays concrete). ``tracer`` is
146+
dynamic per fit.
147+
"""
148+
from autoarray.abstract_ndarray import register_instance_pytree
149+
from autolens.lens.tracer import Tracer
150+
151+
register_instance_pytree(
152+
FitWeak,
153+
no_flatten=("dataset", "_xp", "_redshift_scale_factors"),
132154
)
155+
register_instance_pytree(Tracer, no_flatten=("cosmology",))
133156

134157
def save_attributes(self, paths: af.DirectoryPaths):
135158
"""
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import numpy as np
2+
3+
import autoarray as aa
4+
import autolens as al
5+
6+
from autolens.weak.fit import FitWeak
7+
8+
9+
def _tracer(z_lens=0.5, z_source=1.0):
10+
lens = al.Galaxy(
11+
redshift=z_lens,
12+
mass=al.mp.IsothermalSph(centre=(0.0, 0.0), einstein_radius=1.6),
13+
)
14+
return al.Tracer(galaxies=[lens, al.Galaxy(redshift=z_source)])
15+
16+
17+
def _dataset(redshifts, is_reduced=False, positions=None):
18+
positions = positions or [(1.0, 1.0), (1.0, 1.0), (1.0, 1.0)]
19+
grid = aa.Grid2DIrregular(values=positions)
20+
tracer = _tracer()
21+
dataset = al.SimulatorShearYX(noise_sigma=0.0, seed=1).via_tracer_from(
22+
tracer=tracer, grid=grid
23+
)
24+
dataset.redshifts = aa.ArrayIrregular(values=list(redshifts))
25+
dataset.is_reduced = is_reduced
26+
return dataset
27+
28+
29+
def test__scale_factors__unity_at_ref_zero_at_lens_and_cosmology_between():
30+
dataset = _dataset(redshifts=[1.0, 0.5, 0.75])
31+
tracer = _tracer()
32+
33+
fit = FitWeak(dataset=dataset, tracer=tracer)
34+
factors = fit._redshift_scale_factors
35+
36+
assert factors[0] == 1.0 # at the source plane
37+
assert factors[1] == 0.0 # at the lens plane: unlensed
38+
expected_mid = tracer.cosmology.scaling_factor_between_redshifts_from(
39+
redshift_0=0.5, redshift_1=0.75, redshift_final=1.0
40+
)
41+
np.testing.assert_allclose(factors[2], expected_mid)
42+
assert 0.0 < factors[2] < 1.0
43+
44+
45+
def test__model_shear__scales_per_galaxy():
46+
"""Three galaxies at the SAME position, different redshifts: the model shear must be the
47+
single-plane shear multiplied by each galaxy's beta ratio."""
48+
dataset = _dataset(redshifts=[1.0, 0.5, 0.75])
49+
tracer = _tracer()
50+
51+
fit_scaled = FitWeak(dataset=dataset, tracer=tracer)
52+
53+
dataset_plain = _dataset(redshifts=[1.0, 0.5, 0.75])
54+
dataset_plain.redshifts = None
55+
fit_plain = FitWeak(dataset=dataset_plain, tracer=tracer)
56+
57+
plain = np.asarray(fit_plain.model_shear)
58+
scaled = np.asarray(fit_scaled.model_shear)
59+
factors = fit_scaled._redshift_scale_factors
60+
61+
np.testing.assert_allclose(scaled, factors[:, None] * plain, atol=1e-12)
62+
np.testing.assert_allclose(scaled[1], 0.0, atol=1e-12) # z = z_lens galaxy
63+
64+
65+
def test__no_redshifts__behaviour_unchanged():
66+
"""A dataset without redshifts must reproduce the pre-scaling likelihood exactly."""
67+
grid = aa.Grid2DIrregular(values=[(0.7, 0.5), (1.0, 1.0), (-0.3, 0.6)])
68+
tracer = _tracer()
69+
dataset = al.SimulatorShearYX(noise_sigma=0.3, seed=2).via_tracer_from(
70+
tracer=tracer, grid=grid
71+
)
72+
73+
fit = FitWeak(dataset=dataset, tracer=tracer)
74+
assert fit._redshift_scale_factors is None
75+
76+
# All galaxies AT the reference plane must also match the no-redshift fit exactly.
77+
dataset.redshifts = aa.ArrayIrregular(values=[1.0, 1.0, 1.0])
78+
fit_ref = FitWeak(dataset=dataset, tracer=tracer)
79+
np.testing.assert_allclose(fit_ref.log_likelihood, fit.log_likelihood, rtol=1e-12)
80+
81+
82+
def test__reduced_and_scaled__g_uses_scaled_kappa():
83+
"""For a reduced dataset with redshifts, g_i = s_i*gamma / (1 - s_i*kappa) — both the shear
84+
and the convergence carry the efficiency factor."""
85+
from autogalaxy.operate.lens_calc import LensCalc
86+
87+
dataset = _dataset(redshifts=[0.75, 1.0], is_reduced=True, positions=[(1.0, 1.0), (1.0, 1.0)])
88+
tracer = _tracer()
89+
90+
fit = FitWeak(dataset=dataset, tracer=tracer)
91+
92+
lens_calc = LensCalc.from_tracer(tracer)
93+
gamma = np.asarray(lens_calc.shear_yx_2d_via_hessian_from(grid=dataset.positions))
94+
kappa = np.asarray(lens_calc.convergence_2d_via_hessian_from(grid=dataset.positions))
95+
s = fit._redshift_scale_factors
96+
97+
expected = (s[:, None] * gamma) / (1.0 - s * kappa)[:, None]
98+
np.testing.assert_allclose(np.asarray(fit.model_shear), expected, atol=1e-12)

0 commit comments

Comments
 (0)