Make the persistent compilation cache actually cache pylcm's programs - #397
Conversation
…river) Two tests on the 4-CPU distributed seam: - test_distributed_solve_matches_single_device_per_type: the F6 correctness contract — a sharded solve must equal the single-device solve per type slice. Green now; the co-map fix must preserve it (catches a wrong device-local index). - test_distributed_solve_kernel_does_not_all_gather_continuation_v: the red driver — the backward-induction kernel currently all-gathers the continuation V across the type shard; it must read only its device-local slice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vmap_1d gains co_mapped_in_axes: an optional per-argument in_axes override so a pytree argument's leading axis can be mapped in lockstep with the mapped variables. The backward-induction co-map uses it to slice each next_regime_to_V_arr leaf to the device-local type, so the continuation-V interpolation reads only its own shard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixed, distributed states (e.g. a permanent type sharded one block per device) never transition, so a regime's continuation value depends only on its own slice of the next-period V-array. The grid-search solve kernel now co-maps each such state with the matching axis of every next_regime_to_V_arr leaf that carries it: an outer vmap peels the leading axis off both the state grid and the continuation V, so the interpolation reads only the device-local slice and XLA inserts no all-gather of the full V-array onto every device. - max_Q_over_a splits the co-mapped (leading) states from the inner productmap and wraps it in per-state co-map vmaps; the V-interpolator drops those coordinates and Q_and_F omits the sliced next-states. - processing detects the co-mappable states (distributed and identity-transition) and builds per-state, per-leaf in_axes so a target regime that prunes the state keeps its full leaf. - Simulation is unchanged: it keeps the full continuation V (subjects are not type-aligned across devices), so the co-map applies to the solve path only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…seam (#385) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ectations Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add 'age' entry to initial_conditions in epstein_zin.md Run section - Document certainty_equivalent parameter in get_model docstring - Add docstrings to _power_transform and _power_inverse helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…model Rename `TransformedExpectation` to `QuasiArithmeticMean` and `PowerCertaintyEquivalent` to `PowerMean`; the `certainty_equivalent` field, params pseudo-function, and `risk_aversion` parameter are unchanged. Move the engine-side implementation (`CE_VALUE_ARG`, the power transform pair, and `resolve_certainty_equivalent`) into `_lcm/certainty_equivalent.py` so the public module is a thin, deep-module namespace and the solver seam in `Q_and_F.py` only imports the resolver. Collapse the toy `tests/test_models/epstein_zin_health.py` and the example into a single parametrized `lcm_examples.epstein_zin` (`EZRegimeId`, `get_model` with a required `certainty_equivalent` and grid-size knobs whose defaults reproduce the toy numerically). The numpy pinning references pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace `docs/examples/epstein_zin.md` with `docs/examples/epstein_zin.ipynb`: the same recursion, mapping, and pitfalls as markdown cells, plus code cells that solve and simulate a 20-period model for two risk-aversion values and render a plotly figure of mean wealth by age (grey vs accent, direct labels). Update the toctree in `myst.yml` and the link in `examples/index.md`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he example - lcm.aggregators exposes the two standard Koopmans aggregators; the default H is now the public H_linear, and H_epstein_zin is parametrized by the intertemporal elasticity of substitution (curvature rho = 1 - 1/psi computed inside, psi = 1 as the Cobb-Douglas limit). - PowerMean handles risk_aversion = 1 as the geometric-mean (log) limit exp(E[log V']) instead of rejecting it; the numpy reference and pinning tests cover it. - Example notebook: merged the redundant positivity pitfalls, added an expected-utility baseline to the wealth figure, log_level='off'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… trace Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The example model gains three parameters, all defaulting to the previous behavior: income (above the consumption floor, saving becomes possible), health_cost (an out-of-pocket expense while in bad health - uninsurable expense risk in the spirit of the Atal et al. medical spending), and bequest_scale (prices the bequest in consumption-equivalent units; H_epstein_zin is a weighted power mean, so the alive value sits at the scale of per-period consumption and an unscaled sqrt(wealth) bequest makes death the good branch of the certainty equivalent). next_wealth clips to the wealth grid so none of the knobs can push states off-grid. The docs page is rewritten around the fixed model: - Atal, Fang, Karlsson & Ziebarth (2025) is now characterized correctly: their baseline is time-separable CARA expected utility; their robustness specification has exactly the CES-aggregator-around-a- certainty-equivalent structure used here, with a CARA certainty equivalent (expressible as a QuasiArithmeticMean). - The pylcm-mapping section reflects the shipped H_linear/H_epstein_zin and explains that per-period utility must live in consumption units because H is a power mean. - A new pitfall documents the bequest-scaling trap. - The figure simulates 1,000 subjects with saving, health costs, and a gentler mortality hazard, so survivor counts stay meaningful at every plotted age, and the closing text explains the economics: both types dissave early out of impatience, the risk-averse type holds and rebuilds a larger precautionary buffer, and the ranking flips when the bad-health spell becomes too expensive to self-insure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99165d1 to
346b5d0
Compare
b54b0e8 to
3287a1e
Compare
…IES/RA sweep The intro now describes the original Atal, Fang, Karlsson & Ziebarth (2025) model accurately: an annual Yaari life-cycle savings problem over ages 25-94, a seven-category health Markov chain driving expenditure and mortality, and the guaranteed-renewable GLTHI vs short-term insurance contracts. Their baseline is time-separable expected utility (CARA gamma=4e-4, CRRA sigma=4 robustness, delta=0.966); the headline is that GLTHI reaches ~96% of first-best welfare, robust to disentangling risk aversion and the IES (sec. VI.D.1, within 0.7%). A 'what this keeps and drops' paragraph is explicit that the equilibrium contract/premium layer is out of scope, so the paper's headline welfare gap is not something this consumer-block example reproduces. A new closing section makes the disentangling concrete: a 2D sweep of the welfare cost of the uninsurable health-expense risk over a log-2 grid of risk aversion and the IES, with expected utility marked as the exact anti-diagonal (IES = 1/gamma). The cost is a risk premium — it roughly triples down the risk-aversion axis and barely moves along the IES axis — so an EU model, confined to the anti-diagonal, confounds the two. This is exactly the degree of freedom the certainty_equivalent seam adds and the lever the paper's robustness section pulls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
346b5d0 to
6859de6
Compare
3287a1e to
3b07a53
Compare
6859de6 to
c42565e
Compare
3b07a53 to
87b488f
Compare
The example model enters at age 25 rather than 60, matching the annual horizon of Atal et al. instead of a retirement-only slice. Only the entry age changes and periods stay annual, so the discount factor needs no recompounding. The docs page runs the full 25-to-85 lifecycle: 2,000 subjects under a Gompertz-like mortality hazard (low when young, rising with age), which keeps a well-populated surviving cohort through midlife so both figures read cleanly. The risk-averse agent holds a persistently larger precautionary buffer, and the welfare cost of the health-expense risk roughly doubles down the risk-aversion axis while barely moving along the IES axis. Prose and quoted numbers track the new lifecycle. The notebook executes in about ten seconds on CPU, within the Read-the-Docs build budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c42565e to
cbbdf1e
Compare
87b488f to
df92648
Compare
The intro markdown cell had lost its newlines and collapsed into a single
line, so its headings and paragraphs ran together on the rendered page.
Rebuild it with one array element per line. Also switch the recursion's
display equation from a ```{math}``` directive — which this project's MyST
does not process inside notebook cells, leaking raw LaTeX — to the $$ form
used by every other notebook, so it typesets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A subject that enters a terminal regime is carried with frozen state through every later period the regime is active. to_dataframe now emits only the entry row per subject (terminal_rows="first", the default); terminal_rows="all" keeps the absorbing representation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stored regression frames capture the absorbing representation, so the comparison must request it explicitly rather than inherit the collapsed default. Only the mortality model exposed this on CI — its subjects die before the horizon under float32 draws — but all four shape-pinning tests get the explicit knob. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JAX writes an executable to the persistent compilation cache only when its compile time exceeds jax_persistent_cache_min_compile_time_secs (default: 1 second). A pylcm model compiles as many small per-regime/per-period programs, most of which fall under that threshold, so the cache stayed empty and every fresh process recompiled the whole model — including the AOT-compiled solve and simulate programs, which do consult the cache once it is populated. Importing lcm zeroes the threshold via jax.config, so it takes effect regardless of whether jax was imported first; a user-set JAX_PERSISTENT_CACHE_MIN_COMPILE_TIME_SECS environment variable wins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cbbdf1e to
ec6e8b5
Compare
df92648 to
8e74e02
Compare
mj023
left a comment
There was a problem hiding this comment.
Looks good, I am not sure if we really need these new tests, the feature is simple enough and the test suite for lcm starts to become quite big. Feel free to include them though.
Thanks! Just not sure how to square this with your comment here? |
Benchmark results (HEAD only — no baseline comparison available)
No merge-base results found locally. Run benchmarks on main first for a comparison. |
I just wanted to prove that I wasn't crazy when I created the Bug Report. The issue seems to happen rather infrequently and I could not find the conditions to reproduce it on every re-run. I am not sure we can find the reason without spending a lot of time on it and because it's not so severe, I think we can safely ignore it for now. Nonetheless I think reducing the compilation time threshold is good, specifically for the reasons mentioned in this PR, that you can get many small functions that are just <1m. |
Integrate the 28 commits feat/dcegm advanced since the branch point: the Epstein-Zin certainty-equivalent seam (#395), device-local continuation-V (#391), persistent-compilation-cache fix (#397), per-subject terminal rows (#396), and the CI/tooling bumps (ty prek hook, action/pixi pins, main merges). Conflict resolution: - src/lcm/__init__.py: keep both the NB-EGM case_piece exports and the new certainty_equivalent exports. - src/_lcm/regime_building/Q_and_F.py: take feat/dcegm's version — its #395 refactor relocated the continuation-operator logic into _lcm/certainty_equivalent.py, superseding nb-egm's inline copy. The MappingLeaf-payload concern nb-egm's deleted unit test guarded is covered by the Q-bundle threading contract and the solve-level nbegm_mappingleaf_threshold agreement tests. - tests/regime_building/test_continuation_operator.py: accept the deletion (replaced by tests/test_certainty_equivalent.py). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PCdJtoqhhjBWhGAo7AXuz
Closes #392.
Diagnosis
The persistent compilation cache was never populated, so every fresh process recompiled the whole model. The cause is not an AOT/cache incompatibility: JAX only writes an executable to the cache when its compile time exceeds
jax_persistent_cache_min_compile_time_secs, which defaults to 1 second. A pylcm model compiles as many small per-regime/per-period programs, and nearly all of them compile in well under a second — so with JAX's default threshold the cache directory stays empty and nothing is ever reused.Once the cache is populated, the AOT-compiled programs (
.lower().compile()in both solve and simulate) hit it just like lazily jitted ones. On the mortality example (CPU and GPU alike), a fresh-process re-run drops from ~1.7s to ~0.5s insimulatewith ~70 cache entries written; the residual is Python-side tracing and lowering, which no XLA cache can skip.What this PR does
Importing
lcmzeroes the threshold:jax.config(notos.environ.setdefault) so it takes effect even whenjaxis imported beforelcm— JAX reads the environment variable only once, at import time.JAX_PERSISTENT_CACHE_MIN_COMPILE_TIME_SECSwins.This makes the third JAX default pylcm sets at import, next to
XLA_PYTHON_CLIENT_PREALLOCATEandJAX_COMPILATION_CACHE_DIR. The user guide (installation and tuning pages) documents it.Tests
Fresh-interpreter subprocess tests in
tests/test_compilation_cache.py: the threshold is zero afterimport jax; import lcm(the adversarial import order); a user-set threshold survives the import; and — the actual #392 regression — compiling a sub-second kernel writes an entry to a fresh cache directory.Base branch
Targets
feat/terminal-rows(#396); lands onmainvia the usual cascade. The change is independent of that branch's content — it just continues the stack.🤖 Generated with Claude Code