Skip to content

Commit ca4f7d3

Browse files
committed
Merge commit '36b9ca78' into feat/nb-egm
2 parents e7c6a3e + 36b9ca7 commit ca4f7d3

2 files changed

Lines changed: 468 additions & 21 deletions

File tree

src/_lcm/egm/interp.py

Lines changed: 265 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
read.
1313
"""
1414

15+
import jax
1516
import jax.numpy as jnp
1617

1718
from lcm.typing import BoolND, Float1D, FloatND, ScalarFloat, ScalarInt
@@ -146,6 +147,208 @@ def interp_on_prepared_grid(
146147
Interpolated values with the shape of `x_query`.
147148
148149
"""
150+
if fp_slopes is None:
151+
return _linear_prepared_read(x_query, search_grid, valid_length, xp, fp)
152+
return _hermite_prepared_read(x_query, search_grid, valid_length, xp, fp, fp_slopes)
153+
154+
155+
def _hermite_prepared_read_primal(
156+
x_query: FloatND,
157+
search_grid: Float1D,
158+
valid_length: ScalarInt,
159+
xp: Float1D,
160+
fp: Float1D,
161+
fp_slopes: Float1D,
162+
) -> FloatND:
163+
"""Hermite value read whose query tangent is defined analytically.
164+
165+
Plain autodiff of the branch program (clip boundaries, zero-weight
166+
guards, the right-continuous bracket search) returns a query derivative
167+
at exact grid nodes that is neither one-sided slope of the interpolant —
168+
and asset-row mode consumes `jax.grad` of this read in its Euler
169+
marginal, with deterministic grid alignments placing queries exactly on
170+
nodes. The custom JVP replaces only the query channel with the analytic
171+
derivative of the selected piece (`_hermite_query_derivative` — see its
172+
docstring for the published convention, including the node-slope rule at
173+
the exact last valid node); all other channels keep the primal program's
174+
tangents.
175+
"""
176+
return _read_prepared_row(x_query, search_grid, valid_length, xp, fp, fp_slopes)
177+
178+
179+
def _hermite_prepared_read_jvp(primals, tangents): # noqa: ANN001, ANN202
180+
x_query, search_grid, valid_length, xp, fp, fp_slopes = primals
181+
x_query_dot, _, _, xp_dot, fp_dot, fp_slopes_dot = tangents
182+
183+
def read_at_fixed_query(
184+
xp_in: Float1D, fp_in: Float1D, fp_slopes_in: Float1D
185+
) -> FloatND:
186+
return _read_prepared_row(
187+
x_query, search_grid, valid_length, xp_in, fp_in, fp_slopes_in
188+
)
189+
190+
primal_out, passive_tangent = jax.jvp(
191+
read_at_fixed_query, (xp, fp, fp_slopes), (xp_dot, fp_dot, fp_slopes_dot)
192+
)
193+
query_derivative = _hermite_query_derivative(
194+
x_query=x_query,
195+
search_grid=search_grid,
196+
valid_length=valid_length,
197+
xp=xp,
198+
fp=fp,
199+
fp_slopes=fp_slopes,
200+
)
201+
return primal_out, passive_tangent + query_derivative * x_query_dot
202+
203+
204+
# Explicit wrapping instead of `@jax.custom_jvp` decorator syntax: the
205+
# beartype import hook wraps every decorated `def` outermost, and wrapping a
206+
# `custom_jvp` instance binds it to its `__call__`, losing `defjvp`.
207+
_hermite_prepared_read = jax.custom_jvp(_hermite_prepared_read_primal)
208+
_hermite_prepared_read.defjvp(_hermite_prepared_read_jvp)
209+
210+
211+
def _hermite_query_derivative(
212+
*,
213+
x_query: FloatND,
214+
search_grid: Float1D,
215+
valid_length: ScalarInt,
216+
xp: Float1D,
217+
fp: Float1D,
218+
fp_slopes: Float1D,
219+
) -> FloatND:
220+
"""Published query derivative of the Hermite value read.
221+
222+
The economics object the parent Euler inversion consumes — the second of
223+
the two derivative objects sharing `_hermite_bracket_derivatives` (the
224+
first is the right germ, the tie selector). Convention:
225+
226+
- strictly below the first node, and strictly above the last valid node:
227+
zero (the read clamps to a constant),
228+
- inside a bracket: the limited-Hermite piece's derivative (the secant
229+
where the correction is inapplicable),
230+
- exactly on an interior node: the right piece's derivative (the bracket
231+
search is right-continuous),
232+
- exactly on the *last* valid node: the node's own limited slope — NOT
233+
zero. Publishing zero at the top wealth node would feed `u'(c) = 0`
234+
into a parent Euler inversion at a reachable grid alignment; the germ's
235+
clamp semantics (`0` at and above the node) apply to tie *selection*
236+
only,
237+
- a zero-width located bracket (an end duplicate): zero — no slope is
238+
defined on it,
239+
- singleton row: zero (constant clamp); empty row: NaN (the read is NaN —
240+
the derivative is non-authoritative and carries the poison).
241+
"""
242+
first, _, _, _, zero_width = _hermite_bracket_derivatives(
243+
x_query=x_query,
244+
search_grid=search_grid,
245+
valid_length=valid_length,
246+
xp=xp,
247+
fp=fp,
248+
fp_slopes=fp_slopes,
249+
)
250+
first_node = search_grid[0]
251+
last_node = search_grid[jnp.maximum(valid_length - 1, 0)]
252+
outside = (x_query < first_node) | (x_query > last_node)
253+
derivative = jnp.where(outside | zero_width, 0.0, first)
254+
derivative = jnp.where(valid_length == 1, 0.0, derivative)
255+
return jnp.where(valid_length == 0, jnp.nan, derivative)
256+
257+
258+
def _linear_prepared_read_primal(
259+
x_query: FloatND,
260+
search_grid: Float1D,
261+
valid_length: ScalarInt,
262+
xp: Float1D,
263+
fp: Float1D,
264+
) -> FloatND:
265+
"""Linear value read whose query tangent is defined analytically.
266+
267+
Same contract as `_hermite_prepared_read`, with the analytic query
268+
derivative of the piecewise-linear interpolant: the located bracket's
269+
secant inside brackets and at nodes (right bracket), zero on both clamp
270+
rays and on degenerate rows.
271+
"""
272+
return _read_prepared_row(x_query, search_grid, valid_length, xp, fp, None)
273+
274+
275+
def _linear_prepared_read_jvp(primals, tangents): # noqa: ANN001, ANN202
276+
x_query, search_grid, valid_length, xp, fp = primals
277+
x_query_dot, _, _, xp_dot, fp_dot = tangents
278+
279+
def read_at_fixed_query(xp_in: Float1D, fp_in: Float1D) -> FloatND:
280+
return _read_prepared_row(
281+
x_query, search_grid, valid_length, xp_in, fp_in, None
282+
)
283+
284+
primal_out, passive_tangent = jax.jvp(
285+
read_at_fixed_query, (xp, fp), (xp_dot, fp_dot)
286+
)
287+
query_derivative = _linear_query_derivative(
288+
x_query=x_query,
289+
search_grid=search_grid,
290+
valid_length=valid_length,
291+
xp=xp,
292+
fp=fp,
293+
)
294+
return primal_out, passive_tangent + query_derivative * x_query_dot
295+
296+
297+
# Same explicit wrapping as the Hermite pair (the beartype hook would bind a
298+
# decorated `custom_jvp` instance to its `__call__`).
299+
_linear_prepared_read = jax.custom_jvp(_linear_prepared_read_primal)
300+
_linear_prepared_read.defjvp(_linear_prepared_read_jvp)
301+
302+
303+
def _linear_query_derivative(
304+
*,
305+
x_query: FloatND,
306+
search_grid: Float1D,
307+
valid_length: ScalarInt,
308+
xp: Float1D,
309+
fp: Float1D,
310+
) -> FloatND:
311+
"""Analytic query derivative of the linear padded-row read.
312+
313+
Same bracket location as the read (`side="right"`, so a node query gets
314+
its right bracket) and the same published-derivative convention as
315+
`_hermite_query_derivative`, with the bracket's secant in place of the
316+
limited-Hermite derivative:
317+
318+
- strictly below the first node and strictly above the last valid node:
319+
zero; exactly on the last node: the last bracket's secant,
320+
- a non-finite value difference (a `-inf` endpoint): zero (the secant
321+
fallback convention),
322+
- a zero-width located bracket: zero,
323+
- singleton row: zero; empty row: NaN (poison-carrying).
324+
"""
325+
upper = jnp.clip(
326+
jnp.searchsorted(search_grid, x_query, side="right"),
327+
1,
328+
jnp.maximum(valid_length - 1, 1),
329+
).astype(jnp.int32)
330+
lower = upper - 1
331+
bracket_width = xp[upper] - xp[lower]
332+
safe_width = jnp.where(bracket_width == 0.0, 1.0, bracket_width)
333+
df = fp[upper] - fp[lower]
334+
secant = jnp.where(jnp.isfinite(df), df, 0.0) / safe_width
335+
first_node = search_grid[0]
336+
last_node = search_grid[jnp.maximum(valid_length - 1, 0)]
337+
outside = (x_query < first_node) | (x_query > last_node)
338+
derivative = jnp.where(outside | (bracket_width == 0.0), 0.0, secant)
339+
derivative = jnp.where(valid_length == 1, 0.0, derivative)
340+
return jnp.where(valid_length == 0, jnp.nan, derivative)
341+
342+
343+
def _read_prepared_row(
344+
x_query: FloatND,
345+
search_grid: Float1D,
346+
valid_length: ScalarInt,
347+
xp: Float1D,
348+
fp: Float1D,
349+
fp_slopes: Float1D | None,
350+
) -> FloatND:
351+
"""Evaluate the padded-row interpolant (the primal branch program)."""
149352
# The bracket indices span the full query mesh (the dominant egm_step
150353
# working buffer at scale), and never exceed the grid length (a few
151354
# hundred), so int32 holds them with vast headroom. Under x64 `searchsorted`
@@ -273,10 +476,67 @@ def interp_right_germ_on_prepared_grid(
273476
Tuple of the right-finiteness flag and the first, second, and third
274477
right derivatives, each with the shape of `x_query`.
275478
479+
The germ is the *tie selector* over the published interpolants, one of two
480+
derivative objects with a deliberately different terminal convention: the
481+
germ's first derivative is zero at and above the last valid node (the read
482+
clamps right of it), while the published query derivative of the value
483+
read (`interp_on_prepared_grid` under autodiff) is the node's own limited
484+
slope *at* the last node and zero only strictly above — the economics the
485+
parent Euler inversion consumes. The two objects agree everywhere else and
486+
share one bracket/limiter implementation.
487+
488+
"""
489+
first, second, third, endpoint_finite, _ = _hermite_bracket_derivatives(
490+
x_query=x_query,
491+
search_grid=search_grid,
492+
valid_length=valid_length,
493+
xp=xp,
494+
fp=fp,
495+
fp_slopes=fp_slopes,
496+
)
497+
# The read clamps to a constant strictly below the first node and at or
498+
# above the last valid one; the germ there is right-finite with all
499+
# derivatives exactly zero. (`search_grid` is `+inf` on the pad, so a
500+
# poisoned all-NaN row lands on the lower clamp.)
501+
first_node = search_grid[0]
502+
last_node = search_grid[jnp.maximum(valid_length - 1, 0)]
503+
on_clamp_ray = (x_query < first_node) | (x_query >= last_node)
504+
right_finite = on_clamp_ray | endpoint_finite
505+
return (
506+
right_finite,
507+
jnp.where(on_clamp_ray, 0.0, first),
508+
jnp.where(on_clamp_ray, 0.0, second),
509+
jnp.where(on_clamp_ray, 0.0, third),
510+
)
511+
512+
513+
def _hermite_bracket_derivatives(
514+
*,
515+
x_query: FloatND,
516+
search_grid: Float1D,
517+
valid_length: ScalarInt,
518+
xp: Float1D,
519+
fp: Float1D,
520+
fp_slopes: Float1D,
521+
) -> tuple[FloatND, FloatND, FloatND, BoolND, BoolND]:
522+
"""Derivatives of the located bracket's limited-Hermite piece.
523+
524+
The single bracket/limiter implementation behind both derivative objects —
525+
the right germ (tie selection) and the published query derivative (the
526+
value read's custom JVP): identical bracket location to
527+
`interp_on_prepared_grid` (`side="right"` puts an on-node query into the
528+
node's right bracket) and the same limiter as `_hermite_correction`, so
529+
these are the derivatives of exactly the polynomial the value read
530+
evaluates. In the bracket's local coordinate `t` the read is
531+
`p(t) = f_l + Δf t + c_l t + (c_u - 2 c_l) t² + (c_l - c_u) t³`.
532+
533+
Returns:
534+
Tuple of the first, second, and third derivatives of the located
535+
piece (the secant and zeros where the Hermite correction is
536+
inapplicable — the linear fallback), the bracket's
537+
endpoint-finiteness flag, and the zero-width-bracket flag.
538+
276539
"""
277-
# Identical bracket location to `interp_on_prepared_grid`: `side="right"`
278-
# puts an on-node query into the node's right bracket, which is exactly the
279-
# bracket whose germ the right-continuous read needs.
280540
upper = jnp.clip(
281541
jnp.searchsorted(search_grid, x_query, side="right"),
282542
1,
@@ -301,10 +561,6 @@ def interp_right_germ_on_prepared_grid(
301561
safe_df = jnp.where(jnp.isfinite(df), df, 0.0)
302562
secant = safe_df / safe_width
303563

304-
# The same limiter as `_hermite_correction`, so these derivatives are the
305-
# derivatives of exactly the polynomial the value read evaluates. In the
306-
# bracket's local coordinate `t` the read is
307-
# `p(t) = f_l + Δf t + c_l t + (c_u - 2 c_l) t² + (c_l - c_u) t³`.
308564
def limit(slope: FloatND) -> FloatND:
309565
same_sign = slope * secant > 0.0
310566
limited = jnp.sign(secant) * jnp.minimum(jnp.abs(slope), 3.0 * jnp.abs(secant))
@@ -333,20 +589,8 @@ def limit(slope: FloatND) -> FloatND:
333589
first = jnp.where(applicable, hermite_first, secant)
334590
second = jnp.where(applicable, hermite_second, 0.0)
335591
third = jnp.where(applicable, hermite_third, 0.0)
336-
# The read clamps to a constant strictly below the first node and at or
337-
# above the last valid one; the germ there is right-finite with all
338-
# derivatives exactly zero. (`search_grid` is `+inf` on the pad, so a
339-
# poisoned all-NaN row lands on the lower clamp.)
340-
first_node = search_grid[0]
341-
last_node = search_grid[jnp.maximum(valid_length - 1, 0)]
342-
on_clamp_ray = (x_query < first_node) | (x_query >= last_node)
343-
right_finite = on_clamp_ray | (jnp.isfinite(fp_lower) & jnp.isfinite(fp_upper))
344-
return (
345-
right_finite,
346-
jnp.where(on_clamp_ray, 0.0, first),
347-
jnp.where(on_clamp_ray, 0.0, second),
348-
jnp.where(on_clamp_ray, 0.0, third),
349-
)
592+
endpoint_finite = jnp.isfinite(fp_lower) & jnp.isfinite(fp_upper)
593+
return first, second, third, endpoint_finite, bracket_width == 0.0
350594

351595

352596
def _interp_between_nodes(

0 commit comments

Comments
 (0)