Add initial_wealth_ratio to calibrate initial household wealth - #1189
Add initial_wealth_ratio to calibrate initial household wealth#1189marcelolafleur wants to merge 9 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1189 +/- ##
==========================================
- Coverage 72.74% 72.62% -0.13%
==========================================
Files 22 22
Lines 5768 5786 +18
==========================================
+ Hits 4196 4202 +6
- Misses 1572 1584 +12
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…he baseline's initial wealth
# Conflicts: # CHANGELOG.md
arihantlodha-cmd
left a comment
There was a problem hiding this comment.
Really nice PR. The docstrings, the writeup of the two designs you tried and dropped, and the OG-PHL before/after all made it easy to follow. I'm still newish here so treat everything below as non-blocking, but I went through it carefully and had a few notes.
Default-unchanged claim holds. I traced the disabled path (initial_wealth_ratio == 0): B0 keeps its original value, B_init[0] = B0 matches current master, and the new in-loop B[0] = B0 just re-asserts that same value, so a default baseline run is unchanged like you said.
The new B[0] = B0 inside the outer loop looks redundant to me. The anchor block runs before B_init[0] = B0 (line 835), so B[0] already holds the anchored B0 going into the loop, and inside the loop only B[1:p.T] gets updated, nothing writes to B[0]. So re-setting it every iteration seems like a no-op in both the enabled and disabled cases. Is it defensive, or is there a spot where B[0] gets overwritten mid-loop that I missed? If it's just defensive, a one-line comment or dropping it would save the next person the same trace.
Test coverage for the anchor itself. test_scale_initial_wealth covers the helper well, but I didn't see a test that an actual solve delivers the target. Something like a @pytest.mark.local baseline TPI run with initial_wealth_ratio > 0 that asserts B[0] is close to initial_wealth_ratio * Y_ss, plus a reform run asserting B[0] matches the baseline's. Would lock in that the wiring stays correct if someone refactors later.
The constraint-checker gap you flagged (negative-consumption roots that satisfy the extended FOCs but pass the checker, since it watches a different consumption object) sounds like it should be its own issue so it doesn't get buried in this PR. Happy to open one that quotes your description if that helps.
Minor stuff: if someone sets initial_wealth_ratio on a reform only and leaves the baseline at default, it gets silently ignored per the clone-baseline design. The docs do say this, but a one-time warning might save a confused afternoon. Also scale = target_B0 / B0_shape would divide by zero if B0_shape were ever 0, not reachable in practice, just noting it.
Thanks for putting this together, the calibrate-to-data motivation is convincing.
|
@marcelolafleur This is a nice addition. I would echo the comments from @arihantlodha-cmd. |
…dd solve-level anchor test
|
@arihantlodha-cmd thank you for such a careful read. You traced the code exactly right, and the writeup made it easy to act on. Thanks @jdebacker for weighing in too. Here's what I did with each point: The The missing test: good catch. There's now a full-run test, marked local like the other long ones. The baseline run has to deliver the wealth target it was given, and the reform run is handed a deliberately wrong value and has to start from the baseline's wealth anyway. I ran it locally and it passes (about 18 minutes). One honest note: since local tests don't run in CI, the codecov number won't move. The lines it flags are the anchor block inside run_TPI, which can only be exercised by a full transition solve. On warning when a reform's value is ignored: I went back and forth on this, and ended up following the model's existing convention instead. A reform already inherits its other starting conditions from the baseline the same way. Initial debt and initial public capital are copied over silently, with no runtime message, because the starting state belongs to the baseline by design. So wealth now does exactly what its siblings do. What I added instead is documentation: the parameter's notes now say to set the same value in the baseline and reform specifications, and explain that the risky mistake is actually the reverse one, a baseline that anchors while the reform keeps the default of 0.0, which would give the two runs different starting points. That guidance ends up in the auto-generated parameter docs, which is more than the model currently says about any of the other inherited initial conditions. The division by zero: agreed it can't happen in practice, so I left it alone to keep the diff small. And yes, please open the constraint checker issue. Quote whatever is useful from the PR description and I'll add detail there. Thanks again for taking the time on this. |
# Conflicts: # CHANGELOG.md
|
thanks @marcelolafleur, these all look like the right calls to me. keeping i'll open the constraint-checker issue now and quote the relevant part of your description. thanks for the thorough response. |
|
|
||
|
|
||
| def scale_initial_wealth( | ||
| initial_b_shape, B0_shape, target_B0, factor, initial_n, p |
There was a problem hiding this comment.
initial_n comes in, but is not used.
| list(np.zeros(p.J).reshape(1, p.J)) + list(initial_b[:-1]) | ||
| ) | ||
| b_splus1init = initial_b | ||
| return (target_B0, b_sinit, b_splus1init, factor, initial_b, initial_n) |
There was a problem hiding this comment.
Why are arguments passed in returned unchanged? Seems unecessary.
…alues at call site
|
@jdebacker good catches, both addressed. The helper originally came out of the dynamic-anchor experiments, where the whole initial_values tuple had to be rebuilt every outer-loop iteration, so I wrote it to take and return the full tuple as a drop-in replacement. When I settled on the static anchor, the helper kept that shape even though it now runs once, before the loop, and the pass-through arguments stopped earning their place. It now takes only what it uses and returns only the three wealth objects it computes; the call site rebuilds initial_values explicitly, so factor and initial_n visibly pass through untouched. Test updated to match. |
|
One more change from this round. @jdebacker, your comments on the helper led me to revisit the reform side, and I ended up simplifying the design rather than just trimming the function. Reforms now inherit the baseline’s initial wealth unconditionally, matching the existing treatment of initial debt and public capital. Previously, a reform left at the default could skip inheritance and start from different wealth than an anchored baseline. That trap is now removed, and This corrects my earlier advice to set the same value in both specifications. The docs and full-run test have been updated for the new behavior, and all 25 non-local TPI tests pass. Sorry for the moving target. |
Addresses #1188.
Adds
initial_wealth_ratio(default 0.0 = disabled, current behavior unchanged): aggregate household wealth in the initial period, relative to steady-state GDP. Initial wealth is a predetermined state, so the anchor is static within the solve: a baseline run scales the steady-state wealth profile once, before the outer loop, so aggregate initial wealth equalsinitial_wealth_ratio× the steady-state Y that the SS solve has already pinned down exactly. Reform runs ignore the parameter and clone the baseline's initial wealth (read from the baseline's saved transition) — the initial state is history, so baseline and reform always share the same initial condition, to machine precision.Changes: the parameter, a
scale_initial_wealthhelper inTPI.pyapplied once at initialization, tests (the existingget_initial_SS_valuesparameterizations pass unchanged), and a CHANGELOG entry.Two designs were tried and rejected, documented here for reviewers: anchoring to initial-period GDP requires either updating the anchor inside the outer loop (both raw and nu-damped updates drove the initial cohorts' root-finding into infeasible negative-consumption roots that satisfy the extended FOCs — and pass the constraint checker, which watches a different consumption object; that checker gap may deserve its own issue) or iterating whole solves. Anchoring to steady-state GDP is exact, static, and robust; calibrations map their data target with one solve iteration and report the delivered initial wealth-to-GDP ratio.
Demonstration on a real country model
OG-PHL baseline transition (2025 start, no policy change), both runs on this branch, converged with transition resource-constraint errors ~0.0014:
cc: @rickecon @jdebacker