Skip to content

Commit b8bc0a3

Browse files
Jammy2211Jammy2211claude
authored
fix: JAX-trace Sérsic stellar-mass CSE deflection path (#500)
The cored-steep-ellipsoid (CSE) decomposition used by Sérsic stellar-mass deflections was only partially ported to JAX, so a jitted likelihood over a Sérsic mass profile crashed with TracerArrayConversionError (surfaced by group/features/advanced/mass_stellar_dark/chaining.py on the release stack). This is an incomplete-JAX-port bug, not a jax-0.10.2 regression — isothermal/ NFW deflect analytically and never exercised the CSE path under jit. P1 (unblock): thread `xp` through the full CSE deflection path — radial_grid_from, decompose_convergence_via_cse, axis_ratio, and _decompose_convergence_via_cse_from (vectorised coefficient matrix + jnp.linalg.lstsq on the JAX path; scipy retained for NumPy). cse_settings_from is rewritten branch-free (xp.where) so a tracer sersic_index traces, with total_cses/sample_points frozen to a static 50/80 (conservative max) for static jit shapes and the per-index dex ranges preserved exactly. NFW/SersicGradient decompose signatures accept `xp` for compatibility. Validated: mass_stellar_dark/chaining.py completes (0 tracebacks); 432 test_autogalaxy mass-profile tests pass; jit round-trip nojit==jit to ~1e-11; numpy-vs-jax parity <=2e-5 for n in [1,4]. NumPy Sérsic-mass deflections shift ~1e-3 (finer CSE count). See PyAutoGalaxy#499. Co-authored-by: Jammy2211 <JNightingale2211@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0faff21 commit b8bc0a3

4 files changed

Lines changed: 104 additions & 59 deletions

File tree

autogalaxy/profiles/mass/abstract/cse.py

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def deflections_via_cse_from(
7979
return xp.vstack((defl_y, defl_x))
8080

8181
@abstractmethod
82-
def decompose_convergence_via_cse(self, grid_radii: np.ndarray):
82+
def decompose_convergence_via_cse(self, grid_radii: np.ndarray, xp=np):
8383
pass
8484

8585
def _decompose_convergence_via_cse_from(
@@ -89,6 +89,7 @@ def _decompose_convergence_via_cse_from(
8989
radii_max: float,
9090
total_cses: int = 25,
9191
sample_points: int = 100,
92+
xp=np,
9293
) -> Tuple[List, List]:
9394
"""
9495
Decompose the convergence of a mass profile into cored steep elliptical (cse) profiles.
@@ -115,34 +116,40 @@ def _decompose_convergence_via_cse_from(
115116
A list of amplitudes and core radii of every cored steep elliptical (cse) the mass profile is decomposed
116117
into.
117118
"""
118-
from scipy.linalg import lstsq
119-
120119
error_sigma = 0.1 # error spread. Could be any value.
121120

122-
r_samples = np.logspace(np.log10(radii_min), np.log10(radii_max), sample_points)
123-
y_samples = np.ones_like(r_samples) / error_sigma
121+
r_samples = xp.logspace(
122+
xp.log10(radii_min), xp.log10(radii_max), sample_points
123+
)
124+
y_samples = xp.ones_like(r_samples) / error_sigma
124125
y_samples_func = func(r_samples)
125126

126-
core_radius_list = np.logspace(
127-
np.log10(radii_min), np.log10(radii_max), total_cses
127+
core_radius_list = xp.logspace(
128+
xp.log10(radii_min), xp.log10(radii_max), total_cses
128129
)
129130

130131
# Different from Masamune's (2106.11464) method, I set S to a series fixed values. So that
131132
# the decomposition can be solved linearly.
132-
133-
coefficient_matrix = np.zeros((sample_points, total_cses))
134-
135-
for j in range(total_cses):
136-
coefficient_matrix[:, j] = self.convergence_cse_1d_from(
137-
r_samples, core_radius_list[j]
138-
)
139-
140-
for k in range(sample_points):
141-
coefficient_matrix[k] /= y_samples_func[k] * error_sigma
142-
143-
results = lstsq(coefficient_matrix, y_samples.T)
144-
145-
amplitude_list = results[0]
133+
#
134+
# The coefficient matrix is built vectorised (rather than the original
135+
# per-column / per-row Python loops that assigned into an ``np.zeros``
136+
# buffer) so the decomposition traces cleanly under ``jax.jit`` when
137+
# ``xp`` is ``jax.numpy`` — a Python loop writing into a NumPy array
138+
# cannot hold tracers. Element ``[k, j]`` matches the original:
139+
# ``convergence_cse_1d_from(r_samples[k], core_radius_list[j]) /
140+
# (y_samples_func[k] * error_sigma)``. Shape ``(sample_points, total_cses)``.
141+
coefficient_matrix = self.convergence_cse_1d_from(
142+
r_samples[:, None], core_radius_list[None, :], xp=xp
143+
) / (y_samples_func[:, None] * error_sigma)
144+
145+
if xp is np:
146+
from scipy.linalg import lstsq
147+
148+
amplitude_list = lstsq(coefficient_matrix, y_samples.T)[0]
149+
else:
150+
amplitude_list = xp.linalg.lstsq(
151+
coefficient_matrix, y_samples.T, rcond=None
152+
)[0]
146153

147154
return amplitude_list, core_radius_list
148155

@@ -168,7 +175,7 @@ def _convergence_2d_via_cse_from(
168175
"""
169176

170177
amplitude_list, core_radius_list = self.decompose_convergence_via_cse(
171-
grid_radii=grid_radii
178+
grid_radii=grid_radii, xp=xp
172179
)
173180

174181
return sum(
@@ -194,9 +201,9 @@ def _deflections_2d_via_cse_from(self, grid: np.ndarray, xp=np, **kwargs) -> np.
194201
"""
195202

196203
amplitude_list, core_radius_list = self.decompose_convergence_via_cse(
197-
grid_radii=self.radial_grid_from(grid=grid, **kwargs)
204+
grid_radii=self.radial_grid_from(grid=grid, xp=xp, **kwargs), xp=xp
198205
)
199-
q = self.axis_ratio()
206+
q = self.axis_ratio(xp)
200207
q2 = q**2.0
201208
grid_y = grid.array[:, 0]
202209
grid_x = grid.array[:, 1]

autogalaxy/profiles/mass/dark/nfw.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def convergence_func(self, grid_radius: float, xp=np) -> float:
175175
)
176176

177177
def decompose_convergence_via_cse(
178-
self, grid_radii: np.ndarray, total_cses=30, sample_points=60
178+
self, grid_radii: np.ndarray, total_cses=30, sample_points=60, xp=np
179179
):
180180
"""
181181
Decompose the convergence of the elliptical NFW mass profile into cored steep elliptical (cse) profiles.
@@ -203,12 +203,14 @@ def decompose_convergence_via_cse(
203203
into.
204204
"""
205205
radii_min = 0.005
206-
radii_max = max(7.5, np.max(grid_radii))
206+
radii_max = xp.maximum(7.5, xp.max(grid_radii))
207207

208208
def nfw_2d(r):
209209
grid_radius = (1.0 / self.scale_radius) * r + 0j
210-
return np.real(
211-
2.0 * self.kappa_s * self.coord_func_g(grid_radius=grid_radius)
210+
return xp.real(
211+
2.0
212+
* self.kappa_s
213+
* self.coord_func_g(grid_radius=grid_radius, xp=xp)
212214
)
213215

214216
return self._decompose_convergence_via_cse_from(
@@ -217,6 +219,7 @@ def nfw_2d(r):
217219
radii_max=radii_max,
218220
total_cses=total_cses,
219221
sample_points=sample_points,
222+
xp=xp,
220223
)
221224

222225
@aa.decorators.to_vector_yx

autogalaxy/profiles/mass/stellar/sersic.py

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,26 @@
1515

1616

1717
def cse_settings_from(
18-
effective_radius, sersic_index, sersic_constant, mass_to_light_gradient
18+
effective_radius, sersic_index, sersic_constant, mass_to_light_gradient, xp=np
1919
):
20+
"""
21+
Return the radial fitting range (``upper_dex`` / ``lower_dex``) and the CSE
22+
decomposition resolution (``total_cses`` / ``sample_points``) used to decompose
23+
a Sersic convergence profile into cored steep ellipsoids.
24+
25+
The standard (``mass_to_light_gradient <= 0.5``) path is written branch-free
26+
using ``xp`` (``jnp.where`` / ``jnp.log10`` under JAX) so it traces cleanly
27+
inside ``jax.jit`` when ``sersic_index`` / ``effective_radius`` are tracers:
28+
Python ``if`` statements cannot branch on a tracer value, and ``jax.jit``
29+
requires static array shapes, so ``total_cses`` / ``sample_points`` are frozen
30+
to the conservative maximum previously used (50 / 80) rather than tuned per
31+
Sersic index. The per-index ``upper_dex`` / ``lower_dex`` *ranges* are preserved
32+
exactly via ``xp.where`` so accuracy is unchanged (only the CSE count can rise).
33+
34+
The ``mass_to_light_gradient > 0.5`` path (radial-gradient Sersic profiles) is
35+
left as the original NumPy branching — it is only reached with a concrete
36+
gradient value and is not part of the standard JAX-traced Sersic path.
37+
"""
2038
if mass_to_light_gradient > 0.5:
2139
if effective_radius > 0.2:
2240
lower_dex = 6.0
@@ -53,31 +71,40 @@ def cse_settings_from(
5371
total_cses = 30
5472
sample_points = 50
5573
else:
56-
upper_dex = np.min(
57-
[
58-
np.log10((23.0 / sersic_constant) ** sersic_index),
59-
0.85 - np.log10(effective_radius),
60-
]
74+
# Static shapes for jax.jit: frozen to the former per-index maximum.
75+
total_cses = 50
76+
sample_points = 80
77+
78+
log_effective_radius = xp.log10(effective_radius)
79+
base_upper_dex = xp.minimum(
80+
xp.log10((23.0 / sersic_constant) ** sersic_index),
81+
0.85 - log_effective_radius,
6182
)
6283

63-
if (sersic_index <= 0.9) and (sersic_index > 0.8):
64-
total_cses = 50
65-
sample_points = 80
66-
upper_dex = np.log10((18.0 / sersic_constant) ** sersic_index)
67-
lower_dex = 4.3 + np.log10(effective_radius)
68-
elif sersic_index <= 0.8:
69-
total_cses = 50
70-
sample_points = 80
71-
upper_dex = np.log10((16.0 / sersic_constant) ** sersic_index)
72-
lower_dex = 4.0 + np.log10(effective_radius)
73-
elif sersic_index > 3.8:
74-
total_cses = 40
75-
sample_points = 50
76-
lower_dex = 4.5 + np.log10(effective_radius)
77-
else:
78-
lower_dex = 3.5 + np.log10(effective_radius)
79-
total_cses = 30
80-
sample_points = 50
84+
# upper_dex / lower_dex select the same per-index ranges as the original
85+
# if/elif ladder, expressed branch-free so a tracer sersic_index traces.
86+
upper_dex = xp.where(
87+
sersic_index <= 0.8,
88+
xp.log10((16.0 / sersic_constant) ** sersic_index),
89+
xp.where(
90+
sersic_index <= 0.9,
91+
xp.log10((18.0 / sersic_constant) ** sersic_index),
92+
base_upper_dex,
93+
),
94+
)
95+
lower_dex = xp.where(
96+
sersic_index <= 0.8,
97+
4.0 + log_effective_radius,
98+
xp.where(
99+
sersic_index <= 0.9,
100+
4.3 + log_effective_radius,
101+
xp.where(
102+
sersic_index > 3.8,
103+
4.5 + log_effective_radius,
104+
3.5 + log_effective_radius,
105+
),
106+
),
107+
)
81108

82109
return upper_dex, lower_dex, total_cses, sample_points
83110

@@ -243,7 +270,7 @@ def image_2d_via_radii_from(self, radius: np.ndarray, xp=np):
243270
)
244271

245272
def decompose_convergence_via_cse(
246-
self, grid_radii: np.ndarray
273+
self, grid_radii: np.ndarray, xp=np
247274
) -> Tuple[List, List]:
248275
"""
249276
Decompose the convergence of the Sersic profile into cored steep elliptical (cse) profiles.
@@ -275,17 +302,20 @@ def decompose_convergence_via_cse(
275302
sersic_index=self.sersic_index,
276303
sersic_constant=self.sersic_constant,
277304
mass_to_light_gradient=0.0,
305+
xp=xp,
278306
)
279307

280-
scaled_effective_radius = self.effective_radius / np.sqrt(self.axis_ratio())
308+
scaled_effective_radius = self.effective_radius / xp.sqrt(
309+
self.axis_ratio(xp)
310+
)
281311
radii_min = scaled_effective_radius / 10.0**lower_dex
282312
radii_max = scaled_effective_radius * 10.0**upper_dex
283313

284314
def sersic_2d(r):
285315
return (
286316
self.mass_to_light_ratio
287317
* self.intensity
288-
* np.exp(
318+
* xp.exp(
289319
-self.sersic_constant
290320
* (
291321
((r / scaled_effective_radius) ** (1.0 / self.sersic_index))
@@ -300,6 +330,7 @@ def sersic_2d(r):
300330
radii_max=radii_max,
301331
total_cses=total_cses,
302332
sample_points=sample_points,
333+
xp=xp,
303334
)
304335

305336
@property

autogalaxy/profiles/mass/stellar/sersic_gradient.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def sersic_gradient_2D(r):
9898
)
9999

100100
def decompose_convergence_via_cse(
101-
self, grid_radii: np.ndarray
101+
self, grid_radii: np.ndarray, xp=np
102102
) -> Tuple[List, List]:
103103
"""
104104
Decompose the convergence of the Sersic profile into singular isothermal elliptical (sie) profiles.
@@ -130,9 +130,12 @@ def decompose_convergence_via_cse(
130130
sersic_index=self.sersic_index,
131131
sersic_constant=self.sersic_constant,
132132
mass_to_light_gradient=self.mass_to_light_gradient,
133+
xp=xp,
133134
)
134135

135-
scaled_effective_radius = self.effective_radius / np.sqrt(self.axis_ratio())
136+
scaled_effective_radius = self.effective_radius / xp.sqrt(
137+
self.axis_ratio(xp)
138+
)
136139
radii_min = scaled_effective_radius / 10.0**lower_dex
137140
radii_max = scaled_effective_radius * 10.0**upper_dex
138141

@@ -141,10 +144,10 @@ def sersic_gradient_2D(r):
141144
self.mass_to_light_ratio
142145
* self.intensity
143146
* (
144-
((self.axis_ratio() * r) / scaled_effective_radius)
147+
((self.axis_ratio(xp) * r) / scaled_effective_radius)
145148
** -self.mass_to_light_gradient
146149
)
147-
* np.exp(
150+
* xp.exp(
148151
-self.sersic_constant
149152
* (
150153
((r / scaled_effective_radius) ** (1.0 / self.sersic_index))
@@ -159,6 +162,7 @@ def sersic_gradient_2D(r):
159162
radii_max=radii_max,
160163
total_cses=total_cses,
161164
sample_points=sample_points,
165+
xp=xp,
162166
)
163167

164168

0 commit comments

Comments
 (0)