Skip to content

Add initial_wealth_ratio to calibrate initial household wealth - #1189

Open
marcelolafleur wants to merge 9 commits into
PSLmodels:masterfrom
marcelolafleur:initial-wealth-ratio
Open

Add initial_wealth_ratio to calibrate initial household wealth#1189
marcelolafleur wants to merge 9 commits into
PSLmodels:masterfrom
marcelolafleur:initial-wealth-ratio

Conversation

@marcelolafleur

@marcelolafleur marcelolafleur commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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 equals initial_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_wealth helper in TPI.py applied once at initialization, tests (the existing get_initial_SS_values parameterizations 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:

  • Disabled (legacy): the imposed condition amounts to a starting wealth-to-GDP of 4.49 — every initial household holds far more than its steady-state counterpart, retirees consume the windfall (aggregate consumption +45% for a year), domestic investment collapses to ~2% of its long-run level, and the debt ratio is paid down to 49.6% against a 60% target.
  • Calibrated (delivered wealth-to-GDP = 3.35, from the PWT capital stock less the IMF public capital stock, the BSP IIP domestic share, and domestically held government debt): consumption smooth (−1.5% in the same year), investment trough at 72% of long-run, debt flat on target through the consolidation window — and the model's primary balance lands exactly on the government's programmed 2025 stance (−2.51 vs −2.51). Calibrate remittances and the full revenue side to Philippine data, and close the budget identity at the debt target EAPD-DRB/OG-PHL#85 carries the full calibration.

before/after

cc: @rickecon @jdebacker

@codecov-commenter

codecov-commenter commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.33333% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.62%. Comparing base (a8b0232) to head (b440d48).

Files with missing lines Patch % Lines
ogcore/TPI.py 33.33% 12 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
unittests 72.62% <33.33%> (-0.13%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
ogcore/TPI.py 35.19% <33.33%> (-0.07%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@marcelolafleur
marcelolafleur marked this pull request as ready for review July 28, 2026 20:23
@marcelolafleur marcelolafleur changed the title Add initial_wealth_ratio to make initial household wealth calibratable Add initial_wealth_ratio to calibrate initial household wealth Jul 28, 2026
@jdebacker
jdebacker requested a review from rickecon August 3, 2026 17:37

@arihantlodha-cmd arihantlodha-cmd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jdebacker

Copy link
Copy Markdown
Member

@marcelolafleur This is a nice addition. I would echo the comments from @arihantlodha-cmd.

@marcelolafleur

Copy link
Copy Markdown
Contributor Author

@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 B[0] = B0 line inside the loop: you're right, it does nothing today. I kept it as insurance against future edits to the line below it, and added a comment saying exactly that, so nobody has to repeat the trace you did.

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.

@arihantlodha-cmd

Copy link
Copy Markdown
Contributor

thanks @marcelolafleur, these all look like the right calls to me. keeping B[0] = B0 with a comment explaining why beats dropping it, and following the existing inherited-initial-condition convention (plus the docs note) is more consistent than a one-off warning. and nice that the full-run test actually exercises the anchor block.

i'll open the constraint-checker issue now and quote the relevant part of your description. thanks for the thorough response.

Comment thread ogcore/TPI.py Outdated


def scale_initial_wealth(
initial_b_shape, B0_shape, target_B0, factor, initial_n, p

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initial_n comes in, but is not used.

Comment thread ogcore/TPI.py Outdated
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are arguments passed in returned unchanged? Seems unecessary.

@marcelolafleur

Copy link
Copy Markdown
Contributor Author

@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.

@marcelolafleur

marcelolafleur commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

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 initial_wealth_ratio only needs to be set on the baseline.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants