Skip to content

Commit 307878a

Browse files
committed
Merge remote-tracking branch 'origin/feat/nb-egm' into feat/nested-nbegm-ez
2 parents 6e7a842 + 5062f2d commit 307878a

11 files changed

Lines changed: 369 additions & 91 deletions

File tree

src/_lcm/egm/published_policy.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,30 @@
1717
action-grid argmax value of the continuous action, subject to a post-read
1818
feasibility check (in-support, finite, positive, within the intrinsic budget).
1919
20-
The gate exists because the stored policy is the **solve-phase** optimum of
21-
one conditional problem, and a linear read is faithful only where the row
22-
carries the coordinates and branch topology it interpolates over. Kept on the
23-
grid-argmax path:
20+
Regimes with discrete actions publish one conditional row per discrete-action
21+
combination — value and policy per branch, on that branch's own endogenous
22+
grid. Simulation then *re-decides* the branch at the subject's state: each
23+
branch's conditional value is interpolated at that branch's own resources
24+
(discrete-only constraints mask infeasible branches to `-inf`), the feasible
25+
branch of highest interpolated value wins, and only the winner's policy is
26+
read. The value read uses the cubic Hermite interpolant with the
27+
`marginal_utility` row as exact node slopes — the same convention the solve
28+
uses to publish values — so the ranking the re-decision sees is the ranking
29+
the solve convention implies.
30+
31+
The gate exists because the stored rows are the **solve-phase** optimum of
32+
one conditional problem each, and a read is faithful only where the rows
33+
carry the coordinates and branch topology they are interpolated over. Kept on
34+
the grid-argmax path:
2435
- regimes with any `Phased` declaration — a phase-variant utility, budget,
2536
transition, or state domain (not only `H`, e.g. naive present bias) makes
2637
the stored policy solve the wrong simulate-phase FOC or puts the policy
2738
rows on the wrong coordinates;
28-
- regimes with discrete actions — the branch is chosen from grid-restricted
29-
Q values, and a branch whose refined conditional optimum lies between
30-
action-grid nodes can lose that comparison yet win after continuous
31-
refinement, so the refined policy could be paired with the wrong branch;
39+
- regimes whose upper-envelope backend does not certify every crossing —
40+
only MSS inserts the exact segment crossing by construction; FUES decides
41+
segment identity by a slope-threshold heuristic (no labels from the
42+
kernel), so its row can silently bridge a missed switch, and RFC/LTM leave
43+
switches between retained nodes;
3244
- regimes with a passive continuous state — each row is the envelope policy
3345
conditional on one passive node, so blending rows across a passive-dimension
3446
branch switch would read an action from neither branch;
@@ -39,14 +51,14 @@
3951
the per-node solve publishes one point per exogenous asset node, not a
4052
crossing-complete row, so interpolating across nodes would mix branches;
4153
- regimes with EV1 taste shocks, whose realized draws perturb the decision.
42-
Publishing conditional values and re-deciding the branch at the simulated
43-
state lifts the discrete-action, passive, process, and asset-row exclusions
44-
(the tracked follow-up).
54+
Publishing per-passive-node / per-process-node conditional values and
55+
re-deciding across those axes the way the discrete-action axis already is
56+
lifts the passive, process, and asset-row exclusions (the tracked follow-up).
4557
4658
Unlike the rolling `EGMCarry` (the cross-period continuation channel, overwritten
4759
each period), this is saved for every solved period and travels to `simulate`
48-
alongside the value-function arrays. Its `endog_grid` rows are shared with the
49-
period's carry; only the `policy` row is additional state.
60+
alongside the value-function arrays. Its rows are shared with the period's
61+
carry; only the `policy` row is additional state.
5062
"""
5163

5264
from collections.abc import Sequence
@@ -64,17 +76,18 @@ class EGMSimPolicy:
6476
6577
Leading axes match the regime's combo layout (discrete states, then passive
6678
states, then discrete actions, as in `EGMCarry`); the trailing axis is the
67-
static refined-grid length. Both rows are NaN-padded in lockstep in the tail.
79+
static refined-grid length. All rows are NaN-padded in lockstep in the tail.
6880
"""
6981

7082
endog_grid: FloatND
7183
"""Endogenous grid in resources space, NaN-padded in the tail.
7284
7385
Shared with the period's `EGMCarry.endog_grid`; weakly ascending per row.
74-
Under a crossing-inserting upper-envelope backend (`fues`, `mss`) the
75-
envelope-switch abscissae are duplicated with one-sided policy copies —
76-
the topology the off-grid read requires; RFC/LTM rows leave switches
77-
between retained nodes and do not qualify for the read.
86+
Under MSS the envelope-switch abscissae are duplicated with one-sided
87+
policy copies — the topology the off-grid read requires; FUES rows are
88+
not certified crossing-complete (segment identity is a slope-threshold
89+
heuristic) and RFC/LTM rows leave switches between retained nodes, so
90+
neither qualifies for the read.
7891
"""
7992

8093
policy: FloatND
@@ -89,6 +102,15 @@ class EGMSimPolicy:
89102
the branch at the subject's state.
90103
"""
91104

105+
marginal_utility: FloatND
106+
"""Marginal utility at `endog_grid` (NaN on padding slots).
107+
108+
Shared with the period's `EGMCarry.marginal_utility`: the value row's
109+
exact node slope by the envelope theorem. Simulation passes it as the
110+
slope row of the cubic Hermite value read, matching the interpolation
111+
convention the solve publishes values under.
112+
"""
113+
92114
row_discrete_state_names: tuple[StateName, ...] = ()
93115
"""Names of the leading discrete-state row axes, in axis order."""
94116

@@ -100,7 +122,7 @@ class EGMSimPolicy:
100122
"""Names of the discrete-action row axes, after the passive states."""
101123

102124

103-
_EGM_SIM_POLICY_ARRAY_FIELDS = ("endog_grid", "policy", "value")
125+
_EGM_SIM_POLICY_ARRAY_FIELDS = ("endog_grid", "policy", "value", "marginal_utility")
104126
_EGM_SIM_POLICY_STATIC_FIELDS = (
105127
"row_discrete_state_names",
106128
"row_passive_state_names",

src/_lcm/egm/step.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,7 @@ def solve_one_combo_over_axes(
617617
endog_grid=grid_stack,
618618
policy=policy_stack,
619619
value=value_stack,
620+
marginal_utility=marginal_stack,
620621
row_discrete_state_names=own_discrete_state_names,
621622
row_passive_state_names=own_passive_state_names,
622623
row_discrete_action_names=tuple(own_discrete_action_values),
@@ -630,7 +631,10 @@ def solve_one_combo_over_axes(
630631
taste_shock_scale=own_taste_shock_scale,
631632
)
632633
sim_policy = EGMSimPolicy(
633-
endog_grid=grid_row, policy=policy_row, value=value_row
634+
endog_grid=grid_row,
635+
policy=policy_row,
636+
value=value_row,
637+
marginal_utility=marginal_row,
634638
)
635639
return V_arr, carry, sim_policy
636640

src/_lcm/regime_building/processing.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -999,12 +999,12 @@ def _build_simulation_phase(
999999
# keeper and adjuster candidates but publishes only the keeper's inner
10001000
# consumption function, so replaying it would pair an adjuster-won
10011001
# value with the keeper's policy;
1002-
# - a crossing-inserting upper-envelope backend — RFC and LTM leave the
1003-
# envelope switch between two retained nodes, so linear policy
1004-
# interpolation across the switch would mix two branch policies. MSS
1005-
# inserts the exact crossing by construction; FUES does so only under the
1006-
# exhaustive scan, so a bounded `fues_n_points_to_scan` is excluded too
1007-
# (see `_envelope_publishes_crossings`);
1002+
# - a crossing-certifying upper-envelope backend (MSS only) — RFC and LTM
1003+
# leave the envelope switch between two retained nodes, and FUES decides
1004+
# segment identity by a slope-threshold heuristic with no labels from
1005+
# the kernel, so its row can bridge a missed switch; only MSS inserts
1006+
# the exact crossing by construction (see
1007+
# `_envelope_publishes_crossings`);
10081008
# - the single-post-state kernel (not asset-row mode) — when a
10091009
# savings-stage function reads the Euler state, DC-EGM solves per
10101010
# exogenous asset node and publishes one optimal point per node, not a
@@ -1066,21 +1066,22 @@ def _build_simulation_phase(
10661066

10671067

10681068
def _envelope_publishes_crossings(solver: DCEGM) -> bool:
1069-
"""Whether the solver's upper envelope inserts every segment crossing.
1069+
"""Whether the solver's upper envelope certifies every segment crossing.
10701070
10711071
A branch-faithful policy read interpolates a row whose envelope switches sit
10721072
at duplicated abscissae carrying both branch policies:
1073-
- `"mss"` ⇒ always: it inserts the exact crossing by construction.
1074-
- `"fues"` ⇒ only under the exhaustive scan
1075-
(`fues_n_points_to_scan is None`). A bounded window may miss a segment's
1076-
continuation past the window's worth of interleaved off-segment
1077-
candidates, dropping that crossing and retaining the dominated
1078-
interlopers.
1079-
- `"rfc"` / `"ltm"` ⇒ never: the switch lands between retained nodes.
1073+
- `"mss"` ⇒ yes: the sweep inserts the exact crossing by construction.
1074+
- `"fues"` ⇒ no: segment identity is decided by thresholding the
1075+
implied-savings slope (`fues_jump_thresh`) — a heuristic — and the
1076+
DC-EGM kernel supplies no segment labels. Two value branches whose
1077+
cross-segment slope stays below the threshold merge into one row with
1078+
no crossing inserted; the row then bridges the switch, and neither an
1079+
exhaustive scan nor a wider window repairs that, because the scan can
1080+
only search within the segment identity it was given. A FUES row is
1081+
therefore not certified crossing-complete for the read.
1082+
- `"rfc"` / `"ltm"` ⇒ no: the switch lands between retained nodes.
10801083
"""
1081-
if solver.upper_envelope == "mss":
1082-
return True
1083-
return solver.upper_envelope == "fues" and solver.fues_n_points_to_scan is None
1084+
return solver.upper_envelope == "mss"
10841085

10851086

10861087
def _regime_has_process_state(v_interpolation_info: VInterpolationInfo) -> bool:

src/_lcm/simulation/simulate.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -853,19 +853,34 @@ def _interp_rows_with_support(
853853
The live support runs from the row's first abscissa to its last finite one
854854
(rows are NaN-padded in the tail). Outside it the interpolant extends an
855855
edge secant (below) or clamps (above) — feasible values that need not
856-
approximate the published function.
856+
approximate the published function. Reads by field:
857+
- `"value"` ⇒ cubic Hermite with the marginal-utility row as exact node
858+
slopes — the convention the solve publishes values under, so the branch
859+
ranking the re-decision sees is the solve convention's ranking;
860+
- `"policy"` ⇒ piecewise linear (the policy row carries no slope data).
857861
"""
858862
rows_f = getattr(sim_policy, field)
859863
rows_x = sim_policy.endog_grid
864+
rows_slope = sim_policy.marginal_utility if field == "value" else None
860865
if index:
861866
rows_x = rows_x[index]
862867
rows_f = rows_f[index]
868+
rows_slope = rows_slope[index] if rows_slope is not None else None
863869
if rows_x.ndim == 1:
864870
rows_x = jnp.broadcast_to(rows_x, (n_subjects, *rows_x.shape))
865871
rows_f = jnp.broadcast_to(rows_f, (n_subjects, *rows_f.shape))
866-
values = vmap(
867-
lambda x_query, xp, fp: interp_on_padded_grid(x_query=x_query, xp=xp, fp=fp)
868-
)(resources, rows_x, rows_f)
872+
if rows_slope is not None:
873+
rows_slope = jnp.broadcast_to(rows_slope, (n_subjects, *rows_slope.shape))
874+
if rows_slope is None:
875+
values = vmap(
876+
lambda x_query, xp, fp: interp_on_padded_grid(x_query=x_query, xp=xp, fp=fp)
877+
)(resources, rows_x, rows_f)
878+
else:
879+
values = vmap(
880+
lambda x_query, xp, fp, fp_slopes: interp_on_padded_grid(
881+
x_query=x_query, xp=xp, fp=fp, fp_slopes=fp_slopes
882+
)
883+
)(resources, rows_x, rows_f, rows_slope)
869884
valid_length = jnp.sum(jnp.isfinite(rows_x), axis=-1)
870885
last_live = jnp.take_along_axis(rows_x, (valid_length - 1)[:, None], axis=-1)[:, 0]
871886
in_support = (resources >= rows_x[:, 0]) & (resources <= last_live)

tests/regime_building/test_egm_policy_read_gate.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,24 +44,30 @@ def test_non_crossing_envelope_backends_do_not_qualify(backend: str):
4444
assert model._regimes["retirement"].simulation.egm_policy_read is None
4545

4646

47-
@pytest.mark.parametrize("backend", ["fues", "mss"])
48-
def test_crossing_inserting_envelope_backends_qualify(backend: str):
49-
"""FUES/MSS rows qualify: duplicated crossing abscissae carry the switch."""
50-
model = _retirement_model_with_backend(backend)
47+
def test_the_mss_backend_qualifies_for_the_policy_read():
48+
"""MSS rows qualify: the sweep inserts the exact segment crossing.
49+
50+
Duplicated crossing abscissae carry both branch policies, so linear
51+
interpolation between retained nodes never mixes branches.
52+
"""
53+
model = _retirement_model_with_backend("mss")
5154
assert model._regimes["retirement"].simulation.egm_policy_read is not None
5255

5356

54-
def test_bounded_fues_scan_does_not_qualify_for_the_policy_read():
55-
"""A finite FUES scan window keeps the grid path: its row may miss crossings.
57+
@pytest.mark.parametrize("n_points_to_scan", [None, 8])
58+
def test_fues_does_not_qualify_for_the_policy_read(n_points_to_scan: int | None):
59+
"""FUES rows keep the grid path: segment identity is a heuristic.
5660
57-
Only the exhaustive scan (`fues_n_points_to_scan=None`) is proven to find a
58-
segment's continuation when more than the window's worth of off-segment
59-
candidates interleave. A bounded window may drop that crossing and retain
60-
the dominated interlopers, so the row lacks the switch topology a
61-
branch-faithful policy read interpolates over.
61+
FUES groups candidates into segments by thresholding the implied-savings
62+
slope (`fues_jump_thresh`), and the DC-EGM kernel supplies no segment
63+
labels. A cross-segment slope below the threshold merges two value
64+
branches into one row — no crossing is inserted and the row bridges the
65+
gap between them — regardless of the scan width, so even the exhaustive
66+
scan does not certify the crossing topology the off-grid read
67+
interpolates over (see `test_fues_segment_detection.py`).
6268
"""
6369
solver = dataclasses.replace(
64-
DCEGM_SOLVER, upper_envelope="fues", fues_n_points_to_scan=8
70+
DCEGM_SOLVER, upper_envelope="fues", fues_n_points_to_scan=n_points_to_scan
6571
)
6672
model = _model_from_alive(
6773
dcegm_retirement.replace(active=lambda age: age < 50, solver=solver)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""The branch re-decision value read follows the solve's Hermite convention.
2+
3+
The solve publishes conditional values by reading the refined row with the
4+
cubic Hermite interpolant, using the marginal-utility row as exact node slopes
5+
(envelope theorem). Simulation compares conditional branch values with the
6+
same interpolant, so the ranking the re-decision sees is the ranking the solve
7+
convention implies — a linear read of the same row can rank two close branches
8+
differently.
9+
"""
10+
11+
import jax.numpy as jnp
12+
import pytest
13+
14+
from _lcm.egm.published_policy import EGMSimPolicy
15+
from _lcm.simulation.simulate import _interp_rows_with_support
16+
17+
18+
def test_value_read_is_cubic_hermite_with_the_marginal_slopes():
19+
"""On nodes `(1, 0)`, `(2, log 2)` with slopes `(1, 1/2)`, the read at `1.5`
20+
is the cubic Hermite value `0.40907`, not the linear chord `0.34657`."""
21+
sim_policy = EGMSimPolicy(
22+
endog_grid=jnp.array([1.0, 2.0]),
23+
policy=jnp.array([0.5, 1.0]),
24+
value=jnp.array([0.0, jnp.log(2.0)]),
25+
marginal_utility=jnp.array([1.0, 0.5]),
26+
)
27+
value, in_support = _interp_rows_with_support(
28+
sim_policy=sim_policy,
29+
field="value",
30+
index=(),
31+
resources=jnp.array([1.5]),
32+
n_subjects=1,
33+
)
34+
assert bool(in_support[0])
35+
assert float(value[0]) == pytest.approx(0.4090736, abs=1e-6)
36+
37+
38+
def test_policy_read_stays_piecewise_linear():
39+
"""The policy read is the linear chord: `0.75` midway between `0.5` and `1.0`.
40+
41+
Only the value row carries exact node slopes (the marginal-utility row via
42+
the envelope theorem); the policy row has no slope data, so its read is
43+
piecewise linear.
44+
"""
45+
sim_policy = EGMSimPolicy(
46+
endog_grid=jnp.array([1.0, 2.0]),
47+
policy=jnp.array([0.5, 1.0]),
48+
value=jnp.array([0.0, jnp.log(2.0)]),
49+
marginal_utility=jnp.array([1.0, 0.5]),
50+
)
51+
policy, _ = _interp_rows_with_support(
52+
sim_policy=sim_policy,
53+
field="policy",
54+
index=(),
55+
resources=jnp.array([1.5]),
56+
n_subjects=1,
57+
)
58+
assert float(policy[0]) == pytest.approx(0.75, abs=1e-9)

tests/simulation/test_simulate_dcegm_branch_redecision.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
comparison, exactly as they mask the grid argmax.
99
"""
1010

11+
import dataclasses
12+
1113
import jax.numpy as jnp
1214
import numpy as np
1315

@@ -24,6 +26,7 @@
2426
from lcm_examples.iskhakov_et_al_2017 import WEALTH_GRID
2527
from tests.test_models.deterministic import retirement_only
2628
from tests.test_models.deterministic.dcegm_variants import (
29+
DCEGM_SOLVER,
2730
dcegm_retirement,
2831
get_retirement_only_params,
2932
)
@@ -65,8 +68,12 @@ def _bequest_utility(wealth: ContinuousState, age: float) -> FloatND:
6568

6669

6770
def _bonus_model(constraints: dict | None = None) -> Model:
71+
# MSS is the only backend whose rows certify every envelope crossing, so
72+
# it is the only one that qualifies for the off-grid read.
73+
solver = dataclasses.replace(DCEGM_SOLVER, upper_envelope="mss")
6874
alive = dcegm_retirement.replace(
6975
active=lambda age: age < 50,
76+
solver=solver,
7077
actions={
7178
"consumption": dcegm_retirement.actions["consumption"],
7279
"take_bonus": DiscreteGrid(BonusChoice),

tests/simulation/test_simulate_dcegm_offgrid.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
hit `c*` — which a grid-restricted argmax cannot.
1414
"""
1515

16+
import dataclasses
17+
1618
import jax.numpy as jnp
1719
import numpy as np
1820

@@ -22,6 +24,7 @@
2224
from lcm_examples.iskhakov_et_al_2017 import WEALTH_GRID, next_wealth_from_savings
2325
from tests.test_models.deterministic import base, dcegm_variants, retirement_only
2426
from tests.test_models.deterministic.dcegm_variants import (
27+
DCEGM_SOLVER,
2528
dcegm_retirement,
2629
get_retirement_only_params,
2730
)
@@ -39,9 +42,14 @@ def _closed_form_model() -> Model:
3942
states={"wealth": LogSpacedGrid(start=0.25, stop=400.0, n_points=400)},
4043
functions={"utility": _bequest_utility},
4144
)
45+
# MSS is the only backend whose rows certify every envelope crossing, so
46+
# it is the only one that qualifies for the off-grid read.
47+
solver = dataclasses.replace(DCEGM_SOLVER, upper_envelope="mss")
4248
return Model(
4349
regimes={
44-
"retirement": dcegm_retirement.replace(active=lambda age: age < 50),
50+
"retirement": dcegm_retirement.replace(
51+
active=lambda age: age < 50, solver=solver
52+
),
4553
"dead": bequest_dead,
4654
},
4755
ages=AgeGrid(start=40, stop=50, step="10Y"),
@@ -95,7 +103,7 @@ def test_discrete_action_regime_consumption_leaves_the_action_grid():
95103
this pins that the off-grid path engages for a discrete-action regime.)
96104
"""
97105
n_periods = 3
98-
model = dcegm_variants.get_full_model("dcegm", n_periods)
106+
model = dcegm_variants.get_full_model("dcegm", n_periods, upper_envelope="mss")
99107
params = dcegm_variants.get_full_params(n_periods)
100108

101109
wealth_nodes = np.asarray(WEALTH_GRID.to_jax())

0 commit comments

Comments
 (0)